1use cargo_toml::Manifest as CargoManifest;
4use futures_util::FutureExt as _;
5use futures_util::future::{BoxFuture, Shared};
6use tracing::info;
7
8use crate::build::{BuildProgress, RustLinkage};
9use crate::framework::{
10 FrameworkChannel, ResolvedFramework, validate_local_cli, validate_resolved_cli,
11};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14enum OpenMode {
15 Full,
16 PreviewBuild,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub struct ManagedBackends {
30 apple: bool,
31 android: bool,
32}
33
34impl ManagedBackends {
35 pub const NONE: Self = Self {
37 apple: false,
38 android: false,
39 };
40
41 pub const ALL: Self = Self {
43 apple: true,
44 android: true,
45 };
46
47 #[must_use]
50 pub const fn for_platform(platform: TargetPlatform) -> Self {
51 Self {
52 apple: crate::apple::platform::is_apple_platform(platform),
53 android: crate::android::platform::is_android_platform(platform),
54 }
55 }
56
57 #[must_use]
59 pub fn for_platforms(platforms: &[TargetPlatform]) -> Self {
60 platforms.iter().fold(Self::NONE, |selected, platform| {
61 selected.union(Self::for_platform(*platform))
62 })
63 }
64
65 #[must_use]
67 pub const fn for_backend(backend: TargetBackend) -> Self {
68 Self {
69 apple: matches!(backend, TargetBackend::Apple),
70 android: matches!(backend, TargetBackend::Android),
71 }
72 }
73
74 #[must_use]
75 const fn union(self, other: Self) -> Self {
76 Self {
77 apple: self.apple || other.apple,
78 android: self.android || other.android,
79 }
80 }
81
82 #[must_use]
84 pub const fn apple(self) -> bool {
85 self.apple
86 }
87
88 #[must_use]
90 pub const fn android(self) -> bool {
91 self.android
92 }
93}
94
95#[derive(Debug, Clone)]
100struct CargoLayout {
101 target_dir: PathBuf,
102 workspace_root: PathBuf,
105}
106
107enum CargoResolution {
108 Local,
109 Locked,
110 Update,
111}
112
113fn spawn_cargo_layout_resolution(
114 current_dir: &Path,
115 framework: Option<ResolvedFramework>,
116 local: bool,
117) -> Shared<BoxFuture<'static, Result<CargoLayout, String>>> {
118 let current_dir = current_dir.to_path_buf();
119 let mode = if local {
120 CargoResolution::Local
121 } else if framework.is_some() {
122 CargoResolution::Locked
123 } else {
124 CargoResolution::Update
125 };
126 smol::spawn(async move {
127 resolve_cargo_layout(¤t_dir, framework, mode)
128 .await
129 .map_err(|error| error.to_string())
130 })
131 .boxed()
132 .shared()
133}
134
135#[derive(Debug, Clone)]
137pub struct Project {
138 root: PathBuf,
139 manifest: Manifest,
140 crate_name: CrateName,
141 cargo_layout: Shared<BoxFuture<'static, Result<CargoLayout, String>>>,
142 linked_packages: Arc<async_lock::OnceCell<Result<BTreeMap<String, String>, String>>>,
143 enabled_features: Arc<async_lock::OnceCell<Result<BTreeSet<String>, String>>>,
144 managed_backends_root: PathBuf,
145}
146
147impl Project {
148 pub async fn select_channel(
153 path: impl AsRef<Path>,
154 channel: FrameworkChannel,
155 ) -> eyre::Result<Self> {
156 let path = smol::fs::canonicalize(path.as_ref()).await?;
157 let water_path = path.join("Water.toml");
158 let cargo_path = path.join("Cargo.toml");
159 let mut water: toml_edit::DocumentMut =
160 smol::fs::read_to_string(&water_path).await?.parse()?;
161 let previous: Manifest = toml::from_str(&water.to_string())?;
162 let mut cargo: toml_edit::DocumentMut =
163 smol::fs::read_to_string(&cargo_path).await?.parse()?;
164 let crate_name = CrateName::try_from(
165 cargo["package"]["name"]
166 .as_str()
167 .ok_or_else(|| eyre::eyre!("channel selection requires a project Cargo.toml"))?,
168 )
169 .map_err(|error| eyre::eyre!(error))?;
170 let (framework, lockfile) = ResolvedFramework::resolve(channel).await?;
171 for (configured, backend) in [
175 (previous.backends.gtk4().is_some(), TargetBackend::Gtk4),
176 (
177 previous.backends.hydrolysis().is_some(),
178 TargetBackend::Hydrolysis,
179 ),
180 (previous.backends.winui().is_some(), TargetBackend::WinUi),
181 (previous.backends.esp32().is_some(), TargetBackend::Dew),
182 ] {
183 if configured {
184 for package in backend.scaffold_packages() {
185 framework.require_distributable(package)?;
186 }
187 }
188 }
189 let mut next = previous.clone();
190 next.waterui_path = None;
191 next.framework = Some(framework.clone());
192 let mut updates =
193 templates::framework_updates(&path, &previous, &next, &crate_name).await?;
194 framework.update_manifest(&mut cargo, &templates::project_patches(&path, &previous)?)?;
195 water.remove("waterui_path");
196 water["framework"] =
197 toml_edit::Item::Table(toml_edit::ser::to_document(&framework)?.into_table());
198 updates.push((water_path, water.to_string().into_bytes()));
199 updates.push((cargo_path, cargo.to_string().into_bytes()));
200 if let Some(lockfile) = lockfile {
201 updates.push((
202 path.join("Cargo.lock"),
203 framework.cargo_lock(&lockfile)?.to_string().into_bytes(),
204 ));
205 updates.push((path.join("Water.lock"), lockfile));
206 }
207 let mut updates: Vec<_> = updates
208 .into_iter()
209 .map(|(file, contents)| (file, Some(contents)))
210 .collect();
211 if channel == FrameworkChannel::Stable
212 && previous
213 .framework
214 .as_ref()
215 .is_some_and(|previous| previous.channel() != Some(FrameworkChannel::Stable))
216 {
217 updates.push((path.join("Water.lock"), None));
218 }
219 apply_channel_selection(&path, framework, updates).await?;
220 Self::open_for_preview_build(path).await.map_err(Into::into)
221 }
222
223 pub async fn run<B: Backend, D: Device>(
235 &self,
236 backend: &B,
237 platform: TargetPlatform,
238 device: D,
239 ) -> Result<Running, FailToRun> {
240 self.run_with_options(backend, platform, device, RunOptions::new(), None)
241 .await
242 }
243
244 pub async fn run_with_options<B: Backend, D: Device>(
253 &self,
254 backend: &B,
255 platform: TargetPlatform,
256 device: D,
257 run_options: RunOptions,
258 progress: Option<BuildProgress>,
259 ) -> Result<Running, FailToRun> {
260 let mut build_options = BuildOptions::development(BuildProfile::Debug);
261 if let Some(progress) = &progress {
262 build_options = build_options.with_progress(progress.clone());
263 }
264 let built = backend
266 .build(self, platform, build_options)
267 .await
268 .map_err(FailToRun::Build)?;
269
270 let mut package_options = PackageOptions::development();
271 if let Some(progress) = progress {
272 package_options = package_options.with_progress(progress);
273 }
274 let artifact = backend
276 .package(self, platform, package_options, &built)
277 .await
278 .map_err(FailToRun::Package)?;
279
280 Self::run_packaged(device, artifact, run_options).await
281 }
282
283 pub async fn run_android_with_options<D: Device + AndroidAbiProvider>(
296 &self,
297 _backend: &AndroidBackend,
298 device: D,
299 run_options: RunOptions,
300 build_options: BuildOptions,
301 progress: Option<BuildProgress>,
302 ) -> Result<Running, FailToRun> {
303 let abi = device.android_abi();
304
305 self.browser_runtime_plan(TargetPlatform::Android, TargetBackend::Android)
306 .await
307 .map_err(FailToRun::Build)?;
308
309 AndroidPlatform::clean_jni_libs(self)
310 .await
311 .map_err(FailToRun::Build)?;
312
313 let mut build_options = build_options;
314 if let Some(progress) = &progress {
315 build_options = build_options.with_progress(progress.clone());
316 }
317 AndroidPlatform::new(abi)
318 .build(self, build_options)
319 .await
320 .map_err(FailToRun::Build)?;
321
322 let mut package_options = PackageOptions::development();
323 if let Some(progress) = progress {
324 package_options = package_options.with_progress(progress);
325 }
326 let artifact = AndroidPlatform::package_with_abis(self, package_options, &[abi])
327 .await
328 .map_err(FailToRun::Package)?;
329
330 Self::run_packaged(device, artifact, run_options).await
331 }
332
333 async fn run_packaged<D: Device>(
334 device: D,
335 artifact: Artifact,
336 run_options: RunOptions,
337 ) -> Result<Running, FailToRun> {
338 info!("Running on device");
339
340 let running = device
341 .run(&crate::toolchain::Host::current(), artifact, run_options)
342 .await?;
343 Ok(running)
344 }
345
346 #[must_use]
350 pub fn root(&self) -> &Path {
351 &self.root
352 }
353
354 pub async fn target_dir(&self) -> eyre::Result<PathBuf> {
360 Ok(self.cargo_layout().await?.target_dir)
361 }
362
363 pub async fn lockfile_path(&self) -> eyre::Result<PathBuf> {
373 Ok(self.cargo_layout().await?.workspace_root.join("Cargo.lock"))
374 }
375
376 async fn cargo_layout(&self) -> eyre::Result<CargoLayout> {
377 self.cargo_layout
378 .clone()
379 .await
380 .map_err(|error| eyre::eyre!(error))
381 }
382
383 pub async fn water_target_dir(&self, linkage: RustLinkage) -> eyre::Result<PathBuf> {
417 let variant = match linkage {
418 RustLinkage::SharedRuntime => "shared",
419 RustLinkage::Static => "static",
420 };
421 Ok(crate::water_dir::shared_target_dir().await?.join(variant))
422 }
423
424 pub async fn toolchain_target_dir(&self, toolchain: &str) -> eyre::Result<PathBuf> {
435 Ok(crate::water_dir::shared_target_dir()
436 .await?
437 .join(format!("toolchain-{toolchain}")))
438 }
439
440 pub async fn host_target_dir(&self) -> eyre::Result<PathBuf> {
452 crate::water_dir::shared_host_target_dir().await
453 }
454
455 #[must_use]
457 pub const fn backends(&self) -> &Backends {
458 &self.manifest.backends
459 }
460
461 #[must_use]
463 pub const fn crate_name(&self) -> &CrateName {
464 &self.crate_name
465 }
466
467 #[must_use]
472 pub fn ffi_crate_name(&self) -> CrateName {
473 self.app_crate_overrides()
474 .and_then(|crates| crates.ffi.clone())
475 .unwrap_or_else(|| generated_crate_name(&self.crate_name, "ffi", &self.root))
476 }
477
478 #[must_use]
480 pub fn preview_ffi_crate_name(&self) -> CrateName {
481 generated_crate_name(&self.crate_name, "preview-ffi", &self.root)
482 }
483
484 #[must_use]
486 pub fn preview_dylib_crate_path(&self, workspace_root: &Path) -> PathBuf {
487 self.preview_ffi_crate_path(workspace_root)
488 }
489
490 #[must_use]
492 pub fn preview_dylib_crate_name(&self) -> CrateName {
493 self.preview_ffi_crate_name()
494 }
495
496 #[must_use]
498 pub fn gtk_backend_crate_name(&self) -> CrateName {
499 self.app_crate_overrides()
500 .and_then(|crates| crates.gtk.clone())
501 .unwrap_or_else(|| generated_crate_name(&self.crate_name, "gtk4", &self.root))
502 }
503
504 #[must_use]
506 pub fn hydrolysis_backend_crate_name(&self) -> CrateName {
507 self.app_crate_overrides()
508 .and_then(|crates| crates.hydrolysis.clone())
509 .unwrap_or_else(|| generated_crate_name(&self.crate_name, "hydrolysis", &self.root))
510 }
511
512 #[must_use]
514 pub fn winui_backend_crate_name(&self) -> CrateName {
515 self.app_crate_overrides()
516 .and_then(|crates| crates.winui.clone())
517 .unwrap_or_else(|| generated_crate_name(&self.crate_name, "winui", &self.root))
518 }
519
520 #[must_use]
522 pub fn esp32_backend_crate_name(&self) -> CrateName {
523 generated_crate_name(&self.crate_name, "esp32", &self.root)
524 }
525
526 #[must_use]
528 pub fn tui_backend_crate_name(&self) -> CrateName {
529 generated_crate_name(&self.crate_name, "tui", &self.root)
530 }
531
532 fn shipped_backend_binary_name(
540 &self,
541 suffix: &str,
542 configured: Option<&CrateName>,
543 ) -> CrateName {
544 configured
545 .cloned()
546 .unwrap_or_else(|| self.crate_name.with_suffix(suffix))
547 }
548
549 #[must_use]
551 pub fn gtk4_binary_name(&self) -> CrateName {
552 self.shipped_backend_binary_name(
553 "gtk4",
554 self.app_crate_overrides()
555 .and_then(|crates| crates.gtk.as_ref()),
556 )
557 }
558
559 #[must_use]
561 pub fn hydrolysis_binary_name(&self) -> CrateName {
562 self.shipped_backend_binary_name(
563 "hydrolysis",
564 self.app_crate_overrides()
565 .and_then(|crates| crates.hydrolysis.as_ref()),
566 )
567 }
568
569 #[must_use]
571 pub fn winui_binary_name(&self) -> CrateName {
572 self.shipped_backend_binary_name(
573 "winui",
574 self.app_crate_overrides()
575 .and_then(|crates| crates.winui.as_ref()),
576 )
577 }
578
579 #[must_use]
581 pub fn esp32_binary_name(&self) -> CrateName {
582 self.shipped_backend_binary_name("esp32", None)
583 }
584
585 #[must_use]
587 pub const fn package_type(&self) -> PackageType {
588 self.manifest.package.package_type
589 }
590
591 #[must_use]
593 pub fn is_playground(&self) -> bool {
594 self.package_type() == PackageType::Playground
595 }
596
597 #[must_use]
599 pub const fn apple_backend(&self) -> Option<&AppleBackend> {
600 self.manifest.backends.apple()
601 }
602
603 #[must_use]
607 pub fn backend_path<B: Backend>(&self) -> PathBuf {
608 self.managed_backends_root.join(B::DEFAULT_PATH)
609 }
610
611 #[must_use]
615 pub fn backend_relative_path<B: Backend>(&self) -> PathBuf {
616 self.manifest.backends.path().join(B::DEFAULT_PATH)
617 }
618
619 #[must_use]
621 pub fn ffi_crate_path(&self) -> PathBuf {
622 self.managed_backends_root.join("ffi")
623 }
624
625 #[must_use]
627 pub fn preview_module_member_path(&self) -> PathBuf {
628 Path::new(crate::templates::PREVIEW_MODULES_DIR)
629 .join(self.preview_ffi_crate_name().to_string())
630 }
631
632 #[must_use]
639 pub fn preview_ffi_crate_path(&self, workspace_root: &Path) -> PathBuf {
640 workspace_root.join(self.preview_module_member_path())
641 }
642
643 #[must_use]
645 pub fn ffi_crate_relative_path(&self) -> PathBuf {
646 self.manifest.backends.path().join("ffi")
647 }
648
649 #[must_use]
651 pub const fn android_backend(&self) -> Option<&AndroidBackend> {
652 self.manifest.backends.android()
653 }
654
655 #[must_use]
657 pub const fn gtk4_backend(&self) -> Option<&crate::gtk4::backend::Gtk4Backend> {
658 self.manifest.backends.gtk4()
659 }
660
661 #[must_use]
663 pub const fn hydrolysis_backend(
664 &self,
665 ) -> Option<&crate::hydrolysis::backend::HydrolysisBackend> {
666 self.manifest.backends.hydrolysis()
667 }
668
669 #[must_use]
671 pub const fn winui_backend(&self) -> Option<&crate::winui::backend::WinUiBackend> {
672 self.manifest.backends.winui()
673 }
674
675 #[must_use]
677 pub const fn esp32_backend(&self) -> Option<&crate::esp32::backend::Esp32Backend> {
678 self.manifest.backends.esp32()
679 }
680
681 #[must_use]
683 pub const fn manifest(&self) -> &Manifest {
684 &self.manifest
685 }
686
687 pub async fn resolved_framework(&self) -> eyre::Result<ResolvedFramework> {
696 ResolvedFramework::for_manifest(self.manifest(), &self.root).await
697 }
698
699 async fn require_distributable_backend(
705 &self,
706 backend: TargetBackend,
707 ) -> Result<(), crate::backend::FailToInitBackend> {
708 let packages = backend.scaffold_packages();
709 if packages.is_empty() {
710 return Ok(());
711 }
712 let framework = self
713 .resolved_framework()
714 .await
715 .map_err(crate::backend::FailToInitBackend::Config)?;
716 for package in packages {
717 framework
718 .require_distributable(package)
719 .map_err(crate::backend::FailToInitBackend::Config)?;
720 }
721 Ok(())
722 }
723
724 pub async fn links_runtime_package(&self, package_name: &str) -> eyre::Result<bool> {
736 let project_root = self.root.clone();
737 let cargo_layout = self.cargo_layout.clone();
738 let packages = self
739 .linked_packages
740 .get_or_init(|| async move {
741 cargo_layout.await?;
742 resolve_linked_runtime_packages(project_root, false)
743 .await
744 .map_err(|error| error.to_string())
745 })
746 .await;
747 match packages {
748 Ok(packages) => Ok(packages.contains_key(package_name)),
749 Err(error) => Err(eyre::eyre!(error.clone())),
750 }
751 }
752
753 pub async fn uses_standard_webview(&self) -> eyre::Result<bool> {
768 let project_root = self.root.clone();
769 let cargo_layout = self.cargo_layout.clone();
770 let features = self
771 .enabled_features
772 .get_or_init(|| async move {
773 cargo_layout.await?;
774 resolve_enabled_features(project_root, false)
775 .await
776 .map_err(|error| error.to_string())
777 })
778 .await;
779 match features {
780 Ok(features) => Ok(features.contains("webview")),
781 Err(error) => Err(eyre::eyre!(error.clone())),
782 }
783 }
784
785 pub async fn resolved_webview_backend(
804 &self,
805 platform: TargetPlatform,
806 backend: TargetBackend,
807 ) -> eyre::Result<Option<ResolvedWebViewBackend>> {
808 if !self.uses_standard_webview().await? {
809 return Ok(None);
810 }
811 let engine = self.linked_browser_engine().await?;
812 engine
813 .unwrap_or(ResolvedWebViewBackend::System)
814 .validate(platform, backend)
815 .map(Some)
816 .map_err(Into::into)
817 }
818
819 pub async fn linked_browser_engine(&self) -> eyre::Result<Option<ResolvedWebViewBackend>> {
827 let cef = self.links_runtime_package("waterui-browser-cef").await?;
828 let wpe = self.links_runtime_package("waterui-browser-wpe").await?;
829 match (cef, wpe) {
830 (true, true) => eyre::bail!(
831 "the application links both waterui-browser-cef and waterui-browser-wpe; \
832 exactly one browser engine can draw a WebView"
833 ),
834 (true, false) => Ok(Some(ResolvedWebViewBackend::Cef)),
835 (false, true) => Ok(Some(ResolvedWebViewBackend::Wpe)),
836 (false, false) => Ok(None),
837 }
838 }
839
840 pub async fn declares_cef_helper(&self) -> eyre::Result<bool> {
856 Ok(crate::project_types::declares_cef_helper(
857 self.linked_browser_engine().await?,
858 ))
859 }
860
861 pub async fn browser_runtime_plan(
868 &self,
869 platform: TargetPlatform,
870 backend: TargetBackend,
871 ) -> eyre::Result<BrowserRuntimePlan> {
872 let webview = self.resolved_webview_backend(platform, backend).await?;
873 let chromium = self.links_runtime_package("waterui-chromium").await?;
874 if chromium && !cef_is_supported(platform, backend) {
875 eyre::bail!(
876 "waterui-chromium requires CEF, which is unsupported for platform {platform:?} \
877 with backend {backend:?}"
878 );
879 }
880 Ok(BrowserRuntimePlan { webview, chromium })
881 }
882
883 #[must_use]
885 pub const fn bundle_identifier(&self) -> &BundleIdentifier {
886 &self.manifest.package.bundle_identifier
887 }
888
889 #[must_use]
891 pub fn assets_path(&self) -> &str {
892 &self.manifest.package.assets_path
893 }
894
895 #[must_use]
897 pub fn assets_dir(&self) -> PathBuf {
898 self.root.join(&self.manifest.package.assets_path)
899 }
900
901 pub async fn clean<B: Backend>(
907 &self,
908 backend: &B,
909 platform: TargetPlatform,
910 ) -> Result<(), eyre::Report> {
911 backend.clean(self, platform).await
912 }
913
914 #[must_use]
920 pub fn generated_crate_names(&self) -> Vec<String> {
921 let mut names: Vec<String> = [
922 self.ffi_crate_name(),
923 self.preview_ffi_crate_name(),
924 self.gtk_backend_crate_name(),
925 self.hydrolysis_backend_crate_name(),
926 self.winui_backend_crate_name(),
927 self.esp32_backend_crate_name(),
928 self.tui_backend_crate_name(),
929 ]
930 .into_iter()
931 .map(|name| name.as_str().to_owned())
932 .collect();
933 names.sort_unstable();
934 names.dedup();
935 names
936 }
937
938 async fn clean_shared_target_units(&self) -> Result<(), eyre::Report> {
944 let removed = crate::water_dir::remove_project_units_from_shared_target(
945 &self.generated_crate_names(),
946 )
947 .await?;
948 for path in &removed {
949 info!(path = %path.display(), "removed this project's unit from the shared target");
950 }
951 Ok(())
952 }
953
954 pub async fn clean_all(&self) -> Result<(), eyre::Report> {
971 use crate::{
972 android::platform::clean_android, apple::platform::clean_apple,
973 esp32::platform::clean_esp32, gtk4::platform::clean_gtk4,
974 hydrolysis::platform::clean_hydrolysis, winui::platform::clean_winui,
975 };
976
977 if self.is_playground() {
978 self.clean_shared_target_units().await?;
981 crate::water_dir::remove_project_build_cache(self.root()).await?;
982 let water_backends_root = self.target_dir().await?.join("water-backends");
988 if water_backends_root.exists() {
989 smol::fs::remove_dir_all(&water_backends_root).await?;
990 }
991 return Ok(());
992 }
993
994 let target_dir = self.target_dir().await?;
996 if target_dir.exists() {
997 smol::fs::remove_dir_all(&target_dir).await?;
998 }
999
1000 self.clean_shared_target_units().await?;
1001
1002 if self.apple_backend().is_some() {
1004 clean_apple(self).await?;
1005 }
1006
1007 if self.android_backend().is_some() {
1009 clean_android(self).await?;
1010 }
1011
1012 if self.gtk4_backend().is_some() || (self.is_playground() && cfg!(target_os = "linux")) {
1014 clean_gtk4(self).await?;
1015 }
1016
1017 if self.hydrolysis_backend().is_some() || self.is_playground() {
1019 clean_hydrolysis(self).await?;
1020 }
1021
1022 if self.winui_backend().is_some() || (self.is_playground() && cfg!(target_os = "windows")) {
1024 clean_winui(self).await?;
1025 }
1026
1027 if self.esp32_backend().is_some() {
1029 clean_esp32(self).await?;
1030 }
1031
1032 let ffi_target_dir = self.ffi_crate_path().join("target");
1033 if ffi_target_dir.exists() {
1034 smol::fs::remove_dir_all(&ffi_target_dir).await?;
1035 }
1036
1037 Ok(())
1038 }
1039
1040 pub async fn package<B: Backend>(
1046 &self,
1047 backend: &B,
1048 platform: TargetPlatform,
1049 options: PackageOptions,
1050 built: &crate::build::BuiltTarget,
1051 ) -> Result<Artifact, eyre::Report> {
1052 backend.package(self, platform, options, built).await
1053 }
1054
1055 fn app_crate_overrides(&self) -> Option<&AppCrates> {
1056 self.manifest.app.as_ref()?.crates.as_ref()
1057 }
1058}
1059
1060#[derive(Debug, thiserror::Error)]
1062pub enum FailToOpenProject {
1063 #[error("Failed to open project manifest: {0}")]
1065 Manifest(FailToOpenManifest),
1066 #[error("Failed to read Cargo.toml: {0}")]
1068 CargoManifest(cargo_toml::Error),
1069
1070 #[error("Failed to get Cargo metadata: {0}")]
1072 TargetDirError(#[from] cargo_metadata::Error),
1073
1074 #[error("Framework compatibility check failed: {0}")]
1076 Framework(eyre::Report),
1077 #[error("Failed to refresh the [patch] tables from the local checkout: {0}")]
1080 LocalPatches(eyre::Report),
1081
1082 #[error("Invalid Cargo.toml: missing crate name")]
1084 MissingCrateName,
1085
1086 #[error("Invalid Cargo.toml crate name: {0}")]
1088 InvalidCrateName(String),
1089
1090 #[error("Project permissions are not allowed in non-playground projects")]
1092 PermissionsNotAllowedInNonPlayground,
1093
1094 #[error(
1096 "Backend project configuration is not allowed in playground projects \
1097 ([backends.esp32] device settings and `backend_path` source \
1098 selections are the exceptions)"
1099 )]
1100 BackendsNotAllowedInPlayground,
1101
1102 #[error("Failed to initialize backend: {0}")]
1104 BackendInit(#[from] crate::backend::FailToInitBackend),
1105
1106 #[error("Failed to prepare managed build cache: {0}")]
1108 BuildCache(#[from] eyre::Report),
1109}
1110
1111#[derive(Debug, thiserror::Error)]
1113pub enum FailToCreateProject {
1114 #[error("Failed to resolve framework: {0}")]
1116 Framework(eyre::Report),
1117 #[error("Directory already exists: {0}")]
1119 DirectoryExists(PathBuf),
1120 #[error("{0} is already a WaterUI project (Water.toml exists)")]
1122 AlreadyProject(PathBuf),
1123 #[error(
1126 "{0} already contains a Cargo.toml; merge the generated scaffold manually or remove it first"
1127 )]
1128 CargoManifestExists(PathBuf),
1129 #[error("Failed to create directory: {0}")]
1131 CreateDir(std::io::Error),
1132 #[error("Failed to scaffold project: {0}")]
1134 Scaffold(std::io::Error),
1135 #[error("Failed to save manifest: {0}")]
1137 SaveManifest(#[from] FailToSaveManifest),
1138
1139 #[error("Failed to get Cargo metadata: {0}")]
1141 TargetDirError(#[from] cargo_metadata::Error),
1142
1143 #[error("Failed to resolve managed build cache: {0}")]
1145 BuildCache(#[from] eyre::Report),
1146
1147 #[error("Failed to initialize git repository: {0}")]
1149 GitInit(std::io::Error),
1150 #[error("Failed to check git repository status: {0}")]
1152 GitStatus(std::io::Error),
1153}
1154
1155#[derive(Debug, Clone)]
1157pub struct CreateOptions {
1158 pub name: String,
1160 pub bundle_identifier: BundleIdentifier,
1162 pub package_type: PackageType,
1164 pub waterui_path: Option<PathBuf>,
1166 pub channel: Option<FrameworkChannel>,
1169 pub framework_manifest: Option<PathBuf>,
1173 pub framework: Option<ResolvedFramework>,
1177 pub author: String,
1179 pub backends: Vec<TargetBackend>,
1184 pub web: Option<WebScaffold>,
1187}
1188
1189#[derive(Debug, Clone)]
1191pub struct WebScaffold {
1192 pub package_manager: web::PackageManager,
1194 pub include_arg: String,
1197}
1198
1199impl CreateOptions {
1200 fn crate_name(&self) -> Result<CrateName, FailToCreateProject> {
1201 let name = self
1202 .name
1203 .chars()
1204 .map(|character| {
1205 if character.is_alphanumeric() {
1206 character.to_ascii_lowercase()
1207 } else {
1208 '_'
1209 }
1210 })
1211 .collect::<String>();
1212 CrateName::try_from(name).map_err(|error| {
1213 FailToCreateProject::Scaffold(std::io::Error::new(
1214 std::io::ErrorKind::InvalidInput,
1215 error,
1216 ))
1217 })
1218 }
1219
1220 async fn resolve_framework(&mut self) -> eyre::Result<(ResolvedFramework, Option<Vec<u8>>)> {
1226 let selected = [
1227 self.waterui_path.is_some(),
1228 self.channel.is_some(),
1229 self.framework_manifest.is_some(),
1230 self.framework.is_some(),
1231 ]
1232 .into_iter()
1233 .filter(|selected| *selected)
1234 .count();
1235 if selected > 1 {
1236 eyre::bail!(
1237 "a framework channel, a local source path, a framework manifest, \
1238 and a resolved framework are mutually exclusive"
1239 );
1240 }
1241 if let Some(path) = &self.waterui_path {
1242 let path = path.clone();
1247 let root = unblock(move || dunce::canonicalize(path)).await?;
1248 self.waterui_path = Some(root.clone());
1249 return Ok((ResolvedFramework::for_local_checkout(&root).await?, None));
1250 }
1251 if let Some(path) = &self.framework_manifest {
1252 return ResolvedFramework::resolve_manifest(path).await;
1253 }
1254 if let Some(framework) = &self.framework {
1255 return Ok((framework.clone(), None));
1256 }
1257 ResolvedFramework::resolve(self.channel.unwrap_or_default()).await
1258 }
1259}
1260
1261impl Project {
1262 pub(crate) async fn scaffold_ffi_companion(
1263 &self,
1264 ) -> Result<(), crate::backend::FailToInitBackend> {
1265 let manifest = self.manifest();
1266 let app_name = manifest
1267 .package
1268 .name
1269 .chars()
1270 .filter(|c| c.is_alphanumeric())
1271 .collect::<String>();
1272 let webview_enabled = self
1273 .uses_standard_webview()
1274 .await
1275 .map_err(crate::backend::FailToInitBackend::Config)?;
1276 let chromium_enabled = self
1277 .links_runtime_package("waterui-chromium")
1278 .await
1279 .map_err(crate::backend::FailToInitBackend::Config)?;
1280 let browser_engine = self
1281 .linked_browser_engine()
1282 .await
1283 .map_err(crate::backend::FailToInitBackend::Config)?;
1284 let framework = self
1285 .resolved_framework()
1286 .await
1287 .map_err(crate::backend::FailToInitBackend::Config)?;
1288 let ctx = TemplateContext::for_project_manifest(
1289 manifest,
1290 self.crate_name().clone(),
1291 app_name,
1292 &framework,
1293 )
1294 .with_backend_project_path(self.ffi_crate_path())
1295 .with_project_root_path(self.root.clone())
1296 .with_webview_enabled(webview_enabled)
1297 .with_chromium_enabled(chromium_enabled)
1298 .with_browser_engine(browser_engine);
1299
1300 templates::ffi::scaffold(&self.ffi_crate_path(), &ctx, &self.ffi_crate_name())
1301 .await
1302 .map_err(crate::backend::FailToInitBackend::Io)?;
1303
1304 let lockfile = self
1305 .lockfile_path()
1306 .await
1307 .map_err(crate::backend::FailToInitBackend::Config)?;
1308 templates::ffi::seed_lockfile(&self.ffi_crate_path(), &lockfile)
1309 .await
1310 .map_err(crate::backend::FailToInitBackend::Io)
1311 }
1312
1313 pub async fn scaffold_preview_ffi_companion(
1319 &self,
1320 workspace_root: &Path,
1321 ) -> Result<PathBuf, crate::backend::FailToInitBackend> {
1322 let manifest = self.manifest();
1323 let app_name = manifest
1324 .package
1325 .name
1326 .chars()
1327 .filter(|c| c.is_alphanumeric())
1328 .collect::<String>();
1329 let framework = self
1330 .resolved_framework()
1331 .await
1332 .map_err(crate::backend::FailToInitBackend::Config)?;
1333 let ctx = TemplateContext::for_project_manifest(
1334 manifest,
1335 self.crate_name().clone(),
1336 app_name,
1337 &framework,
1338 )
1339 .with_backend_project_path(self.preview_ffi_crate_path(workspace_root))
1340 .with_project_root_path(self.root.clone());
1341
1342 let crate_path = self.preview_ffi_crate_path(workspace_root);
1343 templates::preview_ffi::scaffold(&crate_path, &ctx, &self.preview_ffi_crate_name())
1344 .await
1345 .map_err(crate::backend::FailToInitBackend::Io)?;
1346 Ok(crate_path)
1347 }
1348
1349 async fn remove_ffi_companion_if_unused(&self) -> eyre::Result<()> {
1350 if self.apple_backend().is_some() || self.android_backend().is_some() {
1351 return Ok(());
1352 }
1353
1354 let ffi_path = self.ffi_crate_path();
1355 if ffi_path.exists() {
1356 smol::fs::remove_dir_all(&ffi_path).await?;
1357 }
1358
1359 Ok(())
1360 }
1361
1362 pub async fn create(
1374 path: impl AsRef<Path>,
1375 options: CreateOptions,
1376 ) -> Result<Self, FailToCreateProject> {
1377 let path = path.as_ref().to_path_buf();
1378
1379 if path.exists() {
1381 return Err(FailToCreateProject::DirectoryExists(path));
1382 }
1383
1384 Self::scaffold_project(path, options).await
1385 }
1386
1387 pub async fn init(
1397 path: impl AsRef<Path>,
1398 options: CreateOptions,
1399 ) -> Result<Self, FailToCreateProject> {
1400 let path = path.as_ref().to_path_buf();
1401 if path.join("Water.toml").exists() {
1402 return Err(FailToCreateProject::AlreadyProject(path));
1403 }
1404 if path.join("Cargo.toml").exists() {
1405 return Err(FailToCreateProject::CargoManifestExists(path));
1406 }
1407 Self::scaffold_project(path, options).await
1408 }
1409
1410 async fn scaffold_project(
1411 path: PathBuf,
1412 mut options: CreateOptions,
1413 ) -> Result<Self, FailToCreateProject> {
1414 let crate_name = options.crate_name()?;
1416 let (framework, lockfile) = options
1417 .resolve_framework()
1418 .await
1419 .map_err(FailToCreateProject::Framework)?;
1420
1421 for backend in &options.backends {
1424 for package in backend.scaffold_packages() {
1425 framework
1426 .require_distributable(package)
1427 .map_err(FailToCreateProject::Framework)?;
1428 }
1429 }
1430
1431 smol::fs::create_dir_all(&path)
1435 .await
1436 .map_err(FailToCreateProject::CreateDir)?;
1437
1438 let ctx = TemplateContext::for_create_options(&options, crate_name.clone(), &framework);
1440
1441 let assets_path = default_assets_path();
1444
1445 templates::root::scaffold(&path, &ctx, &assets_path)
1447 .await
1448 .map_err(FailToCreateProject::Scaffold)?;
1449
1450 crate::mcp::ensure_mcp_json(&path)
1453 .await
1454 .map_err(FailToCreateProject::Scaffold)?;
1455 if let Some(lockfile) = lockfile {
1456 let contents = ctx
1457 .framework
1458 .cargo_lock(&lockfile)
1459 .map_err(FailToCreateProject::Framework)?
1460 .to_string();
1461 smol::fs::write(path.join("Water.lock"), lockfile)
1462 .await
1463 .map_err(FailToCreateProject::Scaffold)?;
1464 smol::fs::write(path.join("Cargo.lock"), contents)
1465 .await
1466 .map_err(FailToCreateProject::Scaffold)?;
1467 }
1468
1469 let mut backends = Backends::default();
1471 if options.package_type == PackageType::App {
1472 backends.set_path("backends");
1473 }
1474
1475 let manifest = Manifest {
1476 package: Package {
1477 package_type: options.package_type,
1478 name: options.name.clone(),
1479 bundle_identifier: options.bundle_identifier.clone(),
1480 assets_path,
1481 accessory: false,
1482 },
1483 backends,
1484 waterui_path: options
1485 .waterui_path
1486 .as_ref()
1487 .map(|p| p.display().to_string()),
1488 framework: framework.channel().is_some().then_some(framework),
1491 permissions: BTreeMap::default(),
1492 app: None,
1493 theme: None,
1494 launch: None,
1495 web: options.web.as_ref().map(|scaffold| web::WebConfig {
1496 package_manager: scaffold.package_manager,
1497 }),
1498 assets: None,
1499 };
1500
1501 manifest.save(&path).await?;
1503
1504 Self::ensure_git_init(&path).await?;
1506
1507 let managed_backends_root = if options.package_type == PackageType::Playground {
1508 crate::water_dir::project_build_cache_dir(&path)
1509 .await
1510 .map_err(FailToCreateProject::BuildCache)?
1511 } else {
1512 path.join(manifest.backends.path())
1513 };
1514
1515 let cargo_layout = if let Some(framework) = &manifest.framework {
1516 let layout =
1517 resolve_cargo_layout(&path, Some(framework.clone()), CargoResolution::Update)
1518 .await
1519 .map_err(FailToCreateProject::Framework)?;
1520 futures_util::future::ready(Ok::<CargoLayout, String>(layout))
1521 .boxed()
1522 .shared()
1523 } else {
1524 spawn_cargo_layout_resolution(&path, None, true)
1525 };
1526 Ok(Self {
1527 root: path,
1528 manifest,
1529 crate_name,
1530 cargo_layout,
1531 linked_packages: Arc::new(async_lock::OnceCell::new()),
1532 enabled_features: Arc::new(async_lock::OnceCell::new()),
1533 managed_backends_root,
1534 })
1535 }
1536
1537 async fn ensure_git_init(path: &Path) -> Result<(), FailToCreateProject> {
1542 let mut cmd = Command::new("git");
1545
1546 let is_in_git = command(&mut cmd)
1547 .args(["rev-parse", "--git-dir"])
1548 .current_dir(path)
1549 .output()
1550 .await
1551 .map_err(FailToCreateProject::GitStatus)?
1552 .status
1553 .success();
1554
1555 if !is_in_git {
1556 let mut cmd = Command::new("git");
1558 command(&mut cmd)
1559 .args(["init"])
1560 .current_dir(path)
1561 .status()
1562 .await
1563 .map_err(FailToCreateProject::GitInit)?;
1564 }
1565
1566 Ok(())
1567 }
1568
1569 pub async fn init_apple_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1576 use crate::backend::Backend;
1577
1578 let backend = AppleBackend::init(self).await?;
1579 self.scaffold_ffi_companion().await?;
1580 self.manifest.backends.set_apple(backend);
1581 self.manifest
1582 .save(&self.root)
1583 .await
1584 .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1585 Ok(())
1586 }
1587
1588 pub async fn init_android_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1595 use crate::backend::Backend;
1596
1597 let backend = AndroidBackend::init(self).await?;
1598 self.scaffold_ffi_companion().await?;
1599 self.manifest.backends.set_android(backend);
1600 self.manifest
1601 .save(&self.root)
1602 .await
1603 .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1604 Ok(())
1605 }
1606
1607 pub async fn init_gtk4_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1614 use crate::{backend::Backend, gtk4::backend::Gtk4Backend};
1615
1616 self.require_distributable_backend(TargetBackend::Gtk4)
1617 .await?;
1618 if !cfg!(target_os = "linux") {
1619 return Err(crate::backend::FailToInitBackend::Io(
1620 std::io::Error::other("GTK4 backend is only supported on Linux hosts"),
1621 ));
1622 }
1623
1624 let backend = Gtk4Backend::init(self).await?;
1625 self.manifest.backends.set_gtk4(backend);
1626 self.manifest
1627 .save(&self.root)
1628 .await
1629 .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1630 Ok(())
1631 }
1632
1633 pub async fn init_hydrolysis_backend(
1641 &mut self,
1642 ) -> Result<(), crate::backend::FailToInitBackend> {
1643 use crate::{backend::Backend, hydrolysis::backend::HydrolysisBackend};
1644
1645 self.require_distributable_backend(TargetBackend::Hydrolysis)
1646 .await?;
1647 let backend = HydrolysisBackend::init(self).await?;
1648 self.manifest.backends.set_hydrolysis(backend);
1649 self.manifest
1650 .save(&self.root)
1651 .await
1652 .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1653
1654 crate::mcp::ensure_mcp_json(&self.root).await?;
1658 Ok(())
1659 }
1660
1661 pub async fn init_winui_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1668 use crate::{backend::Backend, winui::backend::WinUiBackend};
1669
1670 self.require_distributable_backend(TargetBackend::WinUi)
1671 .await?;
1672 if !cfg!(target_os = "windows") {
1673 return Err(crate::backend::FailToInitBackend::Io(
1674 std::io::Error::other("WinUI backend is only supported on Windows hosts"),
1675 ));
1676 }
1677
1678 let backend = WinUiBackend::init(self).await?;
1679 self.manifest.backends.set_winui(backend);
1680 self.manifest
1681 .save(&self.root)
1682 .await
1683 .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1684 Ok(())
1685 }
1686
1687 pub async fn init_esp32_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1695 use crate::{backend::Backend, esp32::backend::Esp32Backend};
1696
1697 self.require_distributable_backend(TargetBackend::Dew)
1698 .await?;
1699 let backend = Esp32Backend::init(self).await?;
1700 self.manifest.backends.set_esp32(backend);
1701 self.manifest
1702 .save(&self.root)
1703 .await
1704 .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1705 Ok(())
1706 }
1707
1708 pub async fn set_esp32_chip(
1719 &mut self,
1720 chip: crate::esp32::chip::Esp32Chip,
1721 ) -> eyre::Result<()> {
1722 let current = self.esp32_backend().cloned().unwrap_or_default();
1723 if current.chip() == chip.id() {
1724 return Ok(());
1725 }
1726 self.manifest.backends.set_esp32(current.with_chip(chip));
1727 self.save_manifest().await
1728 }
1729
1730 pub async fn remove_apple_backend(&mut self) -> eyre::Result<()> {
1735 if let Some(backend) = self.apple_backend() {
1736 let path = backend.project_path().to_path_buf();
1737 self.remove_backend_relative_dir(&path).await?;
1738 }
1739 self.manifest.backends.clear_apple();
1740 self.remove_ffi_companion_if_unused().await?;
1741 self.save_manifest().await
1742 }
1743
1744 pub async fn remove_android_backend(&mut self) -> eyre::Result<()> {
1749 if let Some(backend) = self.android_backend() {
1750 let path = backend.project_path().clone();
1751 self.remove_backend_relative_dir(&path).await?;
1752 }
1753 self.manifest.backends.clear_android();
1754 self.remove_ffi_companion_if_unused().await?;
1755 self.save_manifest().await
1756 }
1757
1758 pub async fn remove_gtk4_backend(&mut self) -> eyre::Result<()> {
1763 if let Some(backend) = self.gtk4_backend() {
1764 let path = backend.project_path().clone();
1765 self.remove_backend_relative_dir(&path).await?;
1766 }
1767 self.manifest.backends.clear_gtk4();
1768 self.save_manifest().await
1769 }
1770
1771 pub async fn remove_winui_backend(&mut self) -> eyre::Result<()> {
1776 if let Some(backend) = self.winui_backend() {
1777 let path = backend.project_path().clone();
1778 self.remove_backend_relative_dir(&path).await?;
1779 }
1780 self.manifest.backends.clear_winui();
1781 self.save_manifest().await
1782 }
1783
1784 pub async fn remove_hydrolysis_backend(&mut self) -> eyre::Result<()> {
1789 if let Some(backend) = self.hydrolysis_backend() {
1790 let path = backend.project_path().clone();
1791 self.remove_backend_relative_dir(&path).await?;
1792 }
1793 self.manifest.backends.clear_hydrolysis();
1794 self.save_manifest().await
1795 }
1796
1797 pub async fn remove_esp32_backend(&mut self) -> eyre::Result<()> {
1802 if let Some(backend) = self.esp32_backend() {
1803 let path = backend.project_path().clone();
1804 self.remove_backend_relative_dir(&path).await?;
1805 }
1806 self.manifest.backends.clear_esp32();
1807 self.save_manifest().await
1808 }
1809
1810 pub async fn open(
1822 path: impl AsRef<Path>,
1823 backends: ManagedBackends,
1824 ) -> Result<Self, FailToOpenProject> {
1825 Self::open_with_mode(path, OpenMode::Full, backends).await
1826 }
1827
1828 pub async fn open_for_preview_build(path: impl AsRef<Path>) -> Result<Self, FailToOpenProject> {
1838 Self::open_with_mode(path, OpenMode::PreviewBuild, ManagedBackends::NONE).await
1839 }
1840
1841 async fn refresh_local_patches(project_root: &Path, waterui_path: &Path) -> eyre::Result<()> {
1856 let project_root = project_root.to_path_buf();
1857 let waterui_path = waterui_path.to_path_buf();
1858 unblock(move || {
1859 let checkout = project_root.join(&waterui_path);
1860 let patch_root = templates::patch_manifest_dir(&project_root)?;
1861 if same_directory(&patch_root, &checkout)? {
1862 return Ok(());
1863 }
1864 if !same_directory(&patch_root, &project_root)? {
1865 eyre::bail!(
1870 "This project is a member of the Cargo workspace at {}, so Cargo reads \
1871 [patch] from {} and ignores any copy here. Move the WaterUI checkout's \
1872 [patch] tables — the ones in {} — into that workspace manifest, or take \
1873 the project out of that workspace.",
1874 patch_root.display(),
1875 patch_root.join("Cargo.toml").display(),
1876 checkout.join("Cargo.toml").display(),
1877 );
1878 }
1879 let cargo_path = project_root.join("Cargo.toml");
1880 let text = std::fs::read_to_string(&cargo_path)?;
1881 let current = CargoManifest::from_slice(text.as_bytes())?.patch;
1882 let next = templates::local_framework_patches(&project_root, &waterui_path)?;
1883 if current == next {
1884 return Ok(());
1885 }
1886 let mut document: toml_edit::DocumentMut = text.parse()?;
1887 crate::framework::rewrite_patch_tables(&mut document, ¤t, &next)?;
1888 std::fs::write(&cargo_path, document.to_string())?;
1889 info!(
1890 path = %cargo_path.display(),
1891 "Refreshed the [patch] tables from the local checkout"
1892 );
1893 Ok(())
1894 })
1895 .await
1896 }
1897
1898 #[allow(clippy::too_many_lines)]
1899 async fn open_with_mode(
1900 path: impl AsRef<Path>,
1901 open_mode: OpenMode,
1902 backends: ManagedBackends,
1903 ) -> Result<Self, FailToOpenProject> {
1904 use crate::backend::Backend;
1905
1906 let total_start = std::time::Instant::now();
1907 let path = path.as_ref().to_path_buf();
1908
1909 let manifest_start = std::time::Instant::now();
1910 let manifest = Manifest::open(path.join("Water.toml"))
1911 .await
1912 .map_err(FailToOpenProject::Manifest)?;
1913 if let Some(framework) = &manifest.framework {
1914 framework
1915 .validate_cli()
1916 .map_err(FailToOpenProject::Framework)?;
1917 }
1918 if let Some(local) = &manifest.waterui_path {
1919 validate_local_cli(&path.join(local))
1920 .await
1921 .map_err(FailToOpenProject::Framework)?;
1922 Self::refresh_local_patches(&path, Path::new(local))
1923 .await
1924 .map_err(FailToOpenProject::LocalPatches)?;
1925 }
1926 info!(
1927 path = %path.display(),
1928 open_mode = ?open_mode,
1929 elapsed_ms = manifest_start.elapsed().as_millis(),
1930 "Project::open loaded Water.toml"
1931 );
1932
1933 let cargo_path = path.join("Cargo.toml");
1934
1935 let cargo_manifest_start = std::time::Instant::now();
1936 let cargo_manifest = unblock(move || CargoManifest::from_path(cargo_path))
1937 .await
1938 .map_err(FailToOpenProject::CargoManifest)?;
1939 info!(
1940 path = %path.display(),
1941 open_mode = ?open_mode,
1942 elapsed_ms = cargo_manifest_start.elapsed().as_millis(),
1943 "Project::open loaded Cargo.toml"
1944 );
1945 let crate_name = cargo_manifest
1946 .package
1947 .map(|p| p.name)
1948 .ok_or(FailToOpenProject::MissingCrateName)
1949 .and_then(|value| {
1950 CrateName::try_from(value).map_err(FailToOpenProject::InvalidCrateName)
1951 })?;
1952
1953 let is_playground = manifest.package.package_type == PackageType::Playground;
1954
1955 if !is_playground && !manifest.permissions.is_empty() {
1957 return Err(FailToOpenProject::PermissionsNotAllowedInNonPlayground);
1958 }
1959
1960 if is_playground && manifest.backends.configures_backend_projects() {
1968 return Err(FailToOpenProject::BackendsNotAllowedInPlayground);
1969 }
1970
1971 let cargo_layout = spawn_cargo_layout_resolution(
1972 &path,
1973 manifest.framework.clone(),
1974 manifest.waterui_path.is_some(),
1975 );
1976 cargo_layout
1977 .clone()
1978 .await
1979 .map_err(|error| FailToOpenProject::Framework(eyre::eyre!(error)))?;
1980
1981 let managed_backends_root = if is_playground {
1982 let build_cache_start = std::time::Instant::now();
1983 let root = crate::water_dir::ensure_project_build_cache(&path)
1984 .await
1985 .map_err(FailToOpenProject::BuildCache)?;
1986 info!(
1987 path = %path.display(),
1988 open_mode = ?open_mode,
1989 elapsed_ms = build_cache_start.elapsed().as_millis(),
1990 "Project::open ensured project build cache"
1991 );
1992 root
1993 } else {
1994 path.join(manifest.backends.path())
1995 };
1996
1997 let mut project = Self {
1998 root: path,
1999 manifest,
2000 crate_name,
2001 cargo_layout,
2002 linked_packages: Arc::new(async_lock::OnceCell::new()),
2003 enabled_features: Arc::new(async_lock::OnceCell::new()),
2004 managed_backends_root,
2005 };
2006
2007 let skip_backend_init = std::env::var("WATERUI_SKIP_RUST_BUILD")
2016 .is_ok_and(|value| value == "1")
2017 || std::env::var("ACTION").is_ok() || std::env::var("XCODE_PRODUCT_BUILD_VERSION").is_ok();
2019
2020 if is_playground && !skip_backend_init && open_mode == OpenMode::Full {
2021 if backends.apple() {
2022 let apple_backend_start = std::time::Instant::now();
2023 let apple_backend = AppleBackend::init(&project)
2024 .await
2025 .map_err(FailToOpenProject::BackendInit)?;
2026 info!(
2027 path = %project.root.display(),
2028 elapsed_ms = apple_backend_start.elapsed().as_millis(),
2029 "Project::open initialized Apple backend"
2030 );
2031 project.manifest.backends.set_apple(apple_backend);
2032 }
2033
2034 if backends.android() {
2035 let android_backend_start = std::time::Instant::now();
2036 let android_backend = AndroidBackend::init(&project)
2037 .await
2038 .map_err(FailToOpenProject::BackendInit)?;
2039 info!(
2040 path = %project.root.display(),
2041 elapsed_ms = android_backend_start.elapsed().as_millis(),
2042 "Project::open initialized Android backend"
2043 );
2044 project.manifest.backends.set_android(android_backend);
2045 }
2046
2047 if project.apple_backend().is_some() || project.android_backend().is_some() {
2048 let ffi_companion_start = std::time::Instant::now();
2049 project
2050 .scaffold_ffi_companion()
2051 .await
2052 .map_err(FailToOpenProject::BackendInit)?;
2053 info!(
2054 path = %project.root.display(),
2055 elapsed_ms = ffi_companion_start.elapsed().as_millis(),
2056 "Project::open scaffolded native ffi companion"
2057 );
2058 }
2059 }
2060
2061 if !is_playground
2062 && !skip_backend_init
2063 && open_mode == OpenMode::Full
2064 && (project.apple_backend().is_some() || project.android_backend().is_some())
2065 {
2066 let ffi_companion_start = std::time::Instant::now();
2067 project
2068 .scaffold_ffi_companion()
2069 .await
2070 .map_err(FailToOpenProject::BackendInit)?;
2071 info!(
2072 path = %project.root.display(),
2073 elapsed_ms = ffi_companion_start.elapsed().as_millis(),
2074 "Project::open refreshed native ffi companion"
2075 );
2076 }
2077
2078 info!(
2079 path = %project.root.display(),
2080 open_mode = ?open_mode,
2081 elapsed_ms = total_start.elapsed().as_millis(),
2082 "Project::open completed"
2083 );
2084
2085 Ok(project)
2086 }
2087}
2088
2089impl Project {
2090 async fn save_manifest(&self) -> eyre::Result<()> {
2091 self.manifest.save(&self.root).await.map_err(Into::into)
2092 }
2093
2094 async fn remove_backend_relative_dir(&self, relative_path: &Path) -> eyre::Result<()> {
2095 let backend_path = self.managed_backends_root.join(relative_path);
2096 if backend_path.exists() {
2097 smol::fs::remove_dir_all(&backend_path).await?;
2098 }
2099 Ok(())
2100 }
2101}
2102
2103async fn apply_channel_selection(
2104 root: &Path,
2105 framework: ResolvedFramework,
2106 updates: Vec<(PathBuf, Option<Vec<u8>>)>,
2107) -> eyre::Result<()> {
2108 let mut previous = BTreeMap::new();
2109 for file in updates
2110 .iter()
2111 .map(|(file, _)| file.clone())
2112 .chain([root.join("Cargo.lock")])
2113 {
2114 let contents = match smol::fs::read(&file).await {
2115 Ok(contents) => Some(contents),
2116 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
2117 Err(error) => return Err(error.into()),
2118 };
2119 previous.insert(file, contents);
2120 }
2121 let result = async {
2122 for (file, contents) in &updates {
2123 write_channel_file(file, contents.as_deref()).await?;
2124 }
2125 resolve_cargo_layout(root, Some(framework), CargoResolution::Update).await?;
2126 Ok(())
2127 }
2128 .await;
2129 if let Err(error) = result {
2130 for (file, contents) in previous {
2131 write_channel_file(&file, contents.as_deref())
2132 .await
2133 .map_err(|restore| {
2134 eyre::eyre!("{error}; could not restore {}: {restore}", file.display())
2135 })?;
2136 }
2137 return Err(error);
2138 }
2139 Ok(())
2140}
2141
2142async fn write_channel_file(path: &Path, contents: Option<&[u8]>) -> std::io::Result<()> {
2143 match contents {
2144 Some(contents) => smol::fs::write(path, contents).await,
2145 None => match smol::fs::remove_file(path).await {
2146 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
2147 result => result,
2148 },
2149 }
2150}
2151
2152async fn resolve_cargo_layout(
2153 current_dir: &Path,
2154 framework: Option<ResolvedFramework>,
2155 mode: CargoResolution,
2156) -> eyre::Result<CargoLayout> {
2157 let root = current_dir.to_path_buf();
2158 let metadata = unblock(move || {
2159 let mut command = cargo_metadata::MetadataCommand::new();
2160 command.current_dir(root);
2161 match mode {
2162 CargoResolution::Local => {
2163 command.no_deps();
2164 }
2165 CargoResolution::Locked => {
2166 command.other_options(vec!["--locked".to_string()]);
2167 }
2168 CargoResolution::Update => {}
2169 }
2170 command.exec()
2171 })
2172 .await?;
2173 validate_resolved_cli(&metadata)?;
2174 if let Some(framework) = framework
2175 && framework.channel() != Some(FrameworkChannel::Stable)
2176 {
2177 let lockfile = smol::fs::read(current_dir.join("Water.lock")).await?;
2178 framework.validate_dependencies(&metadata, &lockfile)?;
2179 }
2180
2181 Ok(CargoLayout {
2182 target_dir: metadata.target_directory.into_std_path_buf(),
2183 workspace_root: metadata.workspace_root.into_std_path_buf(),
2184 })
2185}
2186
2187async fn cargo_tree(project_root: &Path, edges: &str, locked: bool) -> eyre::Result<String> {
2194 let application_manifest = dunce::canonicalize(project_root.join("Cargo.toml"))?;
2202 let metadata_manifest = application_manifest.clone();
2203 let metadata = unblock(move || {
2204 let mut command = cargo_metadata::MetadataCommand::new();
2205 command.no_deps().manifest_path(metadata_manifest);
2206 if locked {
2207 command.other_options(vec!["--locked".to_string()]);
2208 }
2209 command.exec()
2210 })
2211 .await?;
2212 let root = metadata
2213 .packages
2214 .iter()
2215 .find(|package| package.manifest_path.as_std_path() == application_manifest)
2216 .ok_or_else(|| {
2217 eyre::eyre!(
2218 "Cargo metadata omitted the application package at {}",
2219 application_manifest.display()
2220 )
2221 })?;
2222 let package_spec = root.id.to_string();
2223 let mut tree = Command::new("cargo");
2224 tree.arg("tree")
2225 .arg("--manifest-path")
2226 .arg(&application_manifest)
2227 .arg("--package")
2228 .arg(package_spec)
2229 .arg("--edges")
2230 .arg(edges)
2231 .arg("--prefix")
2232 .arg("none")
2233 .arg("--format")
2234 .arg("{p}")
2235 .current_dir(project_root);
2236 if locked {
2237 tree.arg("--locked");
2238 }
2239 let output = tree.output().await?;
2240 if !output.status.success() {
2241 return Err(eyre::eyre!(
2242 "failed to resolve runtime dependency graph for {}: {}",
2243 application_manifest.display(),
2244 String::from_utf8_lossy(&output.stderr).trim()
2245 ));
2246 }
2247
2248 String::from_utf8(output.stdout)
2249 .map_err(|error| eyre::eyre!("Cargo runtime dependency graph is not UTF-8: {error}"))
2250}
2251
2252async fn resolve_linked_runtime_packages(
2253 project_root: PathBuf,
2254 locked: bool,
2255) -> eyre::Result<BTreeMap<String, String>> {
2256 let tree = cargo_tree(&project_root, "normal", locked).await?;
2257 let mut linked = BTreeMap::new();
2258 for package in tree.lines() {
2259 let name = package
2260 .split_ascii_whitespace()
2261 .next()
2262 .ok_or_else(|| eyre::eyre!("Cargo emitted an empty runtime dependency entry"))?;
2263 linked.insert(name.to_string(), package.to_string());
2264 }
2265
2266 Ok(linked)
2267}
2268
2269async fn resolve_enabled_features(
2274 project_root: PathBuf,
2275 locked: bool,
2276) -> eyre::Result<BTreeSet<String>> {
2277 let tree = cargo_tree(&project_root, "features", locked).await?;
2278 let mut features = BTreeSet::new();
2279 for node in tree.lines() {
2280 if let Some(feature) = node
2281 .split_once(" feature \"")
2282 .and_then(|(_, rest)| rest.strip_suffix('"'))
2283 {
2284 features.insert(feature.to_string());
2285 }
2286 }
2287 Ok(features)
2288}
2289
2290use std::{
2291 collections::{BTreeMap, BTreeSet},
2292 path::{Path, PathBuf},
2293 sync::Arc,
2294};
2295
2296use serde::{Deserialize, Serialize};
2297use smol::{fs::read_to_string, process::Command, unblock};
2298use waterui_assets_planner::{LaunchConfig, ThemeConfig};
2299
2300use crate::{
2301 android::{backend::AndroidBackend, device::AndroidAbiProvider, platform::AndroidPlatform},
2302 apple::backend::AppleBackend,
2303 backend::{Backend, Backends},
2304 build::{BuildOptions, BuildProfile},
2305 device::{Artifact, Device, FailToRun, RunOptions, Running},
2306 platform::{PackageOptions, TargetBackend, TargetPlatform},
2307 project_types::{BundleIdentifier, CrateName, PermissionKey, generated_crate_name},
2308 templates::{self, TemplateContext},
2309 utils::command,
2310 web,
2311};
2312
2313#[derive(Debug, Serialize, Deserialize, Clone)]
2315pub struct Manifest {
2316 pub package: Package,
2318 #[serde(default, skip_serializing_if = "Backends::is_empty")]
2320 pub backends: Backends,
2321 #[serde(skip_serializing_if = "Option::is_none")]
2325 pub waterui_path: Option<String>,
2326 #[serde(default, skip_serializing_if = "Option::is_none")]
2328 pub framework: Option<ResolvedFramework>,
2329 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2331 pub permissions: BTreeMap<PermissionKey, PermissionEntry>,
2332 #[serde(default, skip_serializing_if = "Option::is_none")]
2334 pub app: Option<AppConfig>,
2335 #[serde(default, skip_serializing_if = "Option::is_none")]
2337 pub theme: Option<ThemeConfig>,
2338 #[serde(default, skip_serializing_if = "Option::is_none")]
2340 pub launch: Option<LaunchConfig>,
2341 #[serde(default, skip_serializing_if = "Option::is_none")]
2343 pub web: Option<web::WebConfig>,
2344 #[serde(default, skip_serializing_if = "Option::is_none")]
2347 pub assets: Option<AssetsConfig>,
2348}
2349
2350#[derive(Debug, Serialize, Deserialize, Clone)]
2352pub struct PermissionEntry {
2353 enable: bool,
2354 description: String,
2356}
2357
2358impl PermissionEntry {
2359 #[must_use]
2361 pub fn enabled(description: impl Into<String>) -> Self {
2362 Self {
2363 enable: true,
2364 description: description.into(),
2365 }
2366 }
2367
2368 #[must_use]
2370 pub const fn is_enabled(&self) -> bool {
2371 self.enable
2372 }
2373
2374 #[must_use]
2376 pub fn description(&self) -> &str {
2377 &self.description
2378 }
2379}
2380
2381#[derive(Debug, thiserror::Error)]
2383pub enum FailToOpenManifest {
2384 #[error("Failed to read manifest file: {0}")]
2386 ReadError(std::io::Error),
2387 #[error("Invalid manifest file: {0}")]
2389 InvalidManifest(toml::de::Error),
2390
2391 #[error("Manifest file not found at the specified path")]
2393 NotFound,
2394}
2395
2396#[derive(Debug, thiserror::Error)]
2398pub enum FailToSaveManifest {
2399 #[error("Failed to serialize manifest: {0}")]
2401 Serialize(toml::ser::Error),
2402 #[error("Failed to write manifest file: {0}")]
2404 Write(std::io::Error),
2405}
2406impl Manifest {
2407 pub async fn open(path: impl AsRef<Path>) -> Result<Self, FailToOpenManifest> {
2414 let path = path.as_ref();
2415 let result = read_to_string(path).await;
2416
2417 match result {
2418 Ok(c) => toml::from_str(&c).map_err(FailToOpenManifest::InvalidManifest),
2419 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(FailToOpenManifest::NotFound),
2420 Err(e) => Err(FailToOpenManifest::ReadError(e)),
2421 }
2422 }
2423
2424 pub async fn save(&self, dir: impl AsRef<Path>) -> Result<(), FailToSaveManifest> {
2430 let path = dir.as_ref().join("Water.toml");
2431 let content = toml::to_string_pretty(self).map_err(FailToSaveManifest::Serialize)?;
2432 smol::fs::write(&path, content)
2433 .await
2434 .map_err(FailToSaveManifest::Write)
2435 }
2436
2437 #[must_use]
2439 pub fn new(package: Package) -> Self {
2440 Self {
2441 package,
2442 backends: Backends::default(),
2443 waterui_path: None,
2444 framework: None,
2445 permissions: BTreeMap::default(),
2446 app: None,
2447 theme: None,
2448 launch: None,
2449 web: None,
2450 assets: None,
2451 }
2452 }
2453}
2454
2455#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2457pub enum ResolvedWebViewBackend {
2458 System,
2460 Wpe,
2462 Cef,
2464}
2465
2466#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2468pub struct BrowserRuntimePlan {
2469 pub webview: Option<ResolvedWebViewBackend>,
2471 pub chromium: bool,
2473}
2474
2475impl BrowserRuntimePlan {
2476 #[must_use]
2479 pub const fn requires_cef(self) -> bool {
2480 self.chromium || matches!(self.webview, Some(ResolvedWebViewBackend::Cef))
2481 }
2482}
2483
2484impl ResolvedWebViewBackend {
2485 #[must_use]
2487 pub const fn supports(self, platform: TargetPlatform, backend: TargetBackend) -> bool {
2488 match self {
2489 Self::System => matches!(
2490 (platform, backend),
2491 (
2492 TargetPlatform::MacOS,
2493 TargetBackend::Apple | TargetBackend::Hydrolysis
2494 ) | (
2495 TargetPlatform::IOS
2496 | TargetPlatform::IOSSimulator
2497 | TargetPlatform::VisionOS
2498 | TargetPlatform::VisionOSSimulator,
2499 TargetBackend::Apple
2500 ) | (TargetPlatform::Android, TargetBackend::Android)
2501 | (TargetPlatform::Linux, TargetBackend::Gtk4)
2502 | (TargetPlatform::Web, TargetBackend::Hydrolysis)
2503 ),
2504 Self::Wpe => {
2505 matches!(platform, TargetPlatform::Linux)
2506 && matches!(backend, TargetBackend::Gtk4 | TargetBackend::Hydrolysis)
2507 }
2508 Self::Cef => cef_is_supported(platform, backend),
2509 }
2510 }
2511
2512 pub const fn validate(
2519 self,
2520 platform: TargetPlatform,
2521 backend: TargetBackend,
2522 ) -> Result<Self, UnsupportedWebViewBackend> {
2523 if self.supports(platform, backend) {
2524 Ok(self)
2525 } else {
2526 Err(UnsupportedWebViewBackend {
2527 resolved: self,
2528 platform,
2529 backend,
2530 })
2531 }
2532 }
2533
2534 #[must_use]
2536 pub const fn as_str(self) -> &'static str {
2537 match self {
2538 Self::System => "system",
2539 Self::Wpe => "wpe",
2540 Self::Cef => "cef",
2541 }
2542 }
2543}
2544
2545const fn cef_is_supported(platform: TargetPlatform, backend: TargetBackend) -> bool {
2546 !matches!(backend, TargetBackend::Dew)
2547 && matches!(
2548 platform,
2549 TargetPlatform::MacOS | TargetPlatform::Linux | TargetPlatform::Windows
2550 )
2551}
2552
2553#[derive(Debug, thiserror::Error)]
2555#[error(
2556 "this application's WebView engine resolves to {resolved:?}, which is unsupported for \
2557 platform {platform:?} with backend {backend:?}. The engine follows the application's \
2558 dependencies: link waterui-browser-cef or waterui-browser-wpe to select one, or \
2559 neither to use the engine this platform bridges."
2560)]
2561pub struct UnsupportedWebViewBackend {
2562 resolved: ResolvedWebViewBackend,
2563 platform: TargetPlatform,
2564 backend: TargetBackend,
2565}
2566
2567#[derive(Debug, Serialize, Deserialize, Clone, Default)]
2570pub struct AssetsConfig {
2571 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2573 pub font: Vec<FontConfig>,
2574}
2575
2576#[derive(Debug, Serialize, Deserialize, Clone)]
2584pub struct FontConfig {
2585 pub name: String,
2587 #[serde(default, skip_serializing_if = "Option::is_none")]
2589 pub local_path: Option<String>,
2590 #[serde(default, skip_serializing_if = "Option::is_none")]
2593 pub remote_path: Option<String>,
2594}
2595
2596#[derive(Debug, Serialize, Deserialize, Clone, Default)]
2598pub struct AppConfig {
2599 #[serde(default, skip_serializing_if = "Option::is_none")]
2601 pub crates: Option<AppCrates>,
2602}
2603
2604#[derive(Debug, Serialize, Deserialize, Clone, Default)]
2606pub struct AppCrates {
2607 #[serde(default, skip_serializing_if = "Option::is_none")]
2609 pub ffi: Option<CrateName>,
2610 #[serde(default, skip_serializing_if = "Option::is_none")]
2612 pub gtk: Option<CrateName>,
2613 #[serde(default, skip_serializing_if = "Option::is_none")]
2615 pub hydrolysis: Option<CrateName>,
2616 #[serde(default, skip_serializing_if = "Option::is_none")]
2618 pub winui: Option<CrateName>,
2619}
2620
2621#[derive(Debug, Serialize, Deserialize, Clone)]
2623pub struct Package {
2624 #[serde(rename = "type")]
2626 pub package_type: PackageType,
2627 pub name: String,
2629 pub bundle_identifier: BundleIdentifier,
2631 #[serde(
2633 default = "default_assets_path",
2634 skip_serializing_if = "is_default_assets_path"
2635 )]
2636 pub assets_path: String,
2637 #[serde(default, skip_serializing_if = "is_false")]
2639 pub accessory: bool,
2640}
2641
2642pub async fn read_project_crate_name(project_path: &Path) -> eyre::Result<String> {
2652 let cargo_toml = project_path.join("Cargo.toml");
2653 let cargo_content = smol::fs::read_to_string(&cargo_toml).await?;
2654 let cargo: toml::Table = cargo_content.parse()?;
2655 cargo
2656 .get("package")
2657 .and_then(|p| p.get("name"))
2658 .and_then(|n| n.as_str())
2659 .map(ToString::to_string)
2660 .ok_or_else(|| eyre::eyre!("Could not find package name in Cargo.toml"))
2661}
2662
2663fn same_directory(left: &Path, right: &Path) -> std::io::Result<bool> {
2670 Ok(std::fs::canonicalize(left)? == std::fs::canonicalize(right)?)
2671}
2672
2673fn default_assets_path() -> String {
2674 "assets".to_string()
2675}
2676
2677fn is_default_assets_path(path: &str) -> bool {
2678 path == "assets"
2679}
2680
2681#[allow(clippy::trivially_copy_pass_by_ref)]
2682const fn is_false(value: &bool) -> bool {
2683 !*value
2684}
2685
2686#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq)]
2688#[serde(rename_all = "lowercase")]
2689pub enum PackageType {
2690 #[default]
2692 App,
2693 Playground,
2696}
2697
2698#[cfg(test)]
2699mod managed_backends_tests {
2700 use super::{ManagedBackends, TargetBackend, TargetPlatform};
2701
2702 #[test]
2705 fn a_platform_selects_only_the_backend_it_builds_with() {
2706 for platform in [
2707 TargetPlatform::MacOS,
2708 TargetPlatform::IOS,
2709 TargetPlatform::IOSSimulator,
2710 ] {
2711 let selected = ManagedBackends::for_platform(platform);
2712 assert!(selected.apple(), "{platform:?} builds with Apple");
2713 assert!(!selected.android(), "{platform:?} leaves Android alone");
2714 }
2715 let selected = ManagedBackends::for_platform(TargetPlatform::Android);
2716 assert!(selected.android());
2717 assert!(!selected.apple());
2718 }
2719
2720 #[test]
2723 fn platforms_without_a_managed_native_backend_select_none() {
2724 for platform in [
2725 TargetPlatform::Linux,
2726 TargetPlatform::Windows,
2727 TargetPlatform::Web,
2728 TargetPlatform::Esp32S3,
2729 TargetPlatform::Esp32C3,
2730 TargetPlatform::Esp32P4,
2731 ] {
2732 assert_eq!(
2733 ManagedBackends::for_platform(platform),
2734 ManagedBackends::NONE,
2735 "{platform:?}"
2736 );
2737 }
2738 }
2739
2740 #[test]
2741 fn several_platforms_select_the_union_of_their_backends() {
2742 assert_eq!(
2743 ManagedBackends::for_platforms(&[TargetPlatform::IOS, TargetPlatform::IOSSimulator]),
2744 ManagedBackends::for_platform(TargetPlatform::MacOS)
2745 );
2746 assert_eq!(
2747 ManagedBackends::for_platforms(&[TargetPlatform::MacOS, TargetPlatform::Android]),
2748 ManagedBackends::ALL
2749 );
2750 assert_eq!(ManagedBackends::for_platforms(&[]), ManagedBackends::NONE);
2751 }
2752
2753 #[test]
2754 fn a_backend_selects_itself_when_it_is_managed_natively() {
2755 assert!(ManagedBackends::for_backend(TargetBackend::Apple).apple());
2756 assert!(!ManagedBackends::for_backend(TargetBackend::Apple).android());
2757 assert!(ManagedBackends::for_backend(TargetBackend::Android).android());
2758 assert!(!ManagedBackends::for_backend(TargetBackend::Android).apple());
2759 for backend in [
2760 TargetBackend::Gtk4,
2761 TargetBackend::Hydrolysis,
2762 TargetBackend::WinUi,
2763 TargetBackend::Dew,
2764 ] {
2765 assert_eq!(
2766 ManagedBackends::for_backend(backend),
2767 ManagedBackends::NONE,
2768 "{backend:?}"
2769 );
2770 }
2771 }
2772}
2773
2774#[cfg(test)]
2775mod channel_tests {
2776 use super::*;
2777
2778 #[test]
2779 fn local_framework_requirement_is_checked_before_project_io() {
2780 smol::block_on(async {
2781 let directory = tempfile::tempdir().unwrap();
2782 let framework_root = directory.path().join("framework");
2783 let project_root = directory.path().join("consumer");
2784 smol::fs::create_dir(&framework_root).await.unwrap();
2785 let mut minimum: cargo_toml::SemVer = env!("CARGO_PKG_VERSION").parse().unwrap();
2786 minimum.major += 1;
2787 let mut metadata = toml::toml! {
2788 [package.metadata.waterui]
2789 minimum-cli-version = "0.1.4"
2790 android-min-api-level = 26
2791 };
2792 metadata["package"]["metadata"]["waterui"]["minimum-cli-version"] =
2793 toml::Value::String(minimum.to_string());
2794 smol::fs::write(
2795 framework_root.join("Cargo.toml"),
2796 toml::to_string(&metadata).unwrap(),
2797 )
2798 .await
2799 .unwrap();
2800 let bundle_identifier =
2801 BundleIdentifier::try_from("dev.waterui.compatibility").unwrap();
2802 let options = CreateOptions {
2803 name: "Compatibility".into(),
2804 bundle_identifier: bundle_identifier.clone(),
2805 package_type: PackageType::Playground,
2806 waterui_path: Some(framework_root),
2807 channel: None,
2808 framework_manifest: None,
2809 framework: None,
2810 author: String::new(),
2811 backends: Vec::new(),
2812 web: None,
2813 };
2814 let error = Project::create(&project_root, options)
2815 .await
2816 .unwrap_err()
2817 .to_string();
2818 assert!(error.contains(&format!("requires waterui-cli >= {minimum}")));
2819 assert!(error.contains(&format!(
2820 "cargo install waterui-cli --git {} --locked",
2821 env!("CARGO_PKG_REPOSITORY")
2822 )));
2823 assert!(!project_root.exists());
2824
2825 smol::fs::create_dir(&project_root).await.unwrap();
2826 let mut manifest = Manifest::new(Package {
2827 name: "Compatibility".into(),
2828 bundle_identifier,
2829 package_type: PackageType::Playground,
2830 assets_path: default_assets_path(),
2831 accessory: false,
2832 });
2833 manifest.waterui_path = Some("../framework".into());
2834 manifest.save(&project_root).await.unwrap();
2835 let error = Project::open_for_preview_build(&project_root)
2836 .await
2837 .unwrap_err()
2838 .to_string();
2839 assert!(error.contains(&format!("requires waterui-cli >= {minimum}")));
2840 assert!(!project_root.join("Cargo.lock").exists());
2841 });
2842 }
2843
2844 #[test]
2845 fn failed_channel_selection_preserves_project_files() {
2846 smol::block_on(async {
2847 let directory = tempfile::tempdir().unwrap();
2848 let root = directory.path();
2849 let originals = [
2850 ("Cargo.toml", b"original manifest".as_slice()),
2851 ("Cargo.lock", b"original dependency lock".as_slice()),
2852 ("Water.toml", b"original project configuration".as_slice()),
2853 ];
2854 for (name, contents) in originals {
2855 smol::fs::write(root.join(name), contents).await.unwrap();
2856 }
2857 let updates = ["Cargo.toml", "Cargo.lock", "Water.toml", "Water.lock"]
2858 .into_iter()
2859 .map(|name| (root.join(name), Some(b"invalid selected manifest".to_vec())))
2860 .collect();
2861 assert!(
2862 apply_channel_selection(
2863 root,
2864 crate::framework::test_fixtures::stable_framework(),
2865 updates
2866 )
2867 .await
2868 .is_err()
2869 );
2870 for (name, contents) in originals {
2871 assert_eq!(smol::fs::read(root.join(name)).await.unwrap(), contents);
2872 }
2873 assert!(!root.join("Water.lock").exists());
2874 });
2875 }
2876}
2877
2878#[cfg(test)]
2879mod webview_backend_tests {
2880 use super::{
2881 ResolvedWebViewBackend, TargetBackend, TargetPlatform, resolve_enabled_features,
2882 resolve_linked_runtime_packages,
2883 };
2884
2885 #[test]
2890 fn the_platform_bridge_is_the_selection_without_an_engine_crate() {
2891 assert_eq!(
2892 ResolvedWebViewBackend::System
2893 .validate(TargetPlatform::MacOS, TargetBackend::Hydrolysis)
2894 .expect("macOS Hydrolysis bridges WKWebView"),
2895 ResolvedWebViewBackend::System
2896 );
2897 assert_eq!(
2898 ResolvedWebViewBackend::System
2899 .validate(TargetPlatform::Linux, TargetBackend::Gtk4)
2900 .expect("GTK bridges WebKitGTK"),
2901 ResolvedWebViewBackend::System
2902 );
2903 assert!(
2904 ResolvedWebViewBackend::System
2905 .validate(TargetPlatform::Linux, TargetBackend::Hydrolysis)
2906 .is_err()
2907 );
2908 assert!(
2909 ResolvedWebViewBackend::System
2910 .validate(TargetPlatform::Windows, TargetBackend::Hydrolysis)
2911 .is_err()
2912 );
2913 }
2914
2915 #[test]
2916 fn unsupported_engine_combinations_fail_before_build() {
2917 assert!(
2918 ResolvedWebViewBackend::Wpe
2919 .validate(TargetPlatform::MacOS, TargetBackend::Hydrolysis)
2920 .is_err()
2921 );
2922 assert!(
2923 ResolvedWebViewBackend::Cef
2924 .validate(TargetPlatform::Android, TargetBackend::Android)
2925 .is_err()
2926 );
2927 assert_eq!(
2928 ResolvedWebViewBackend::Cef
2929 .validate(TargetPlatform::MacOS, TargetBackend::Apple)
2930 .expect("CEF must compose with the native Apple renderer on macOS"),
2931 ResolvedWebViewBackend::Cef
2932 );
2933 }
2934
2935 #[test]
2936 fn cef_is_available_to_every_non_dew_backend_on_desktop_platforms() {
2937 for backend in [
2938 TargetBackend::Apple,
2939 TargetBackend::Android,
2940 TargetBackend::Gtk4,
2941 TargetBackend::Hydrolysis,
2942 ] {
2943 for platform in [
2944 TargetPlatform::MacOS,
2945 TargetPlatform::Linux,
2946 TargetPlatform::Windows,
2947 ] {
2948 assert_eq!(
2949 ResolvedWebViewBackend::Cef
2950 .validate(platform, backend)
2951 .expect("CEF availability must not depend on the WaterUI backend"),
2952 ResolvedWebViewBackend::Cef
2953 );
2954 }
2955 }
2956 }
2957
2958 #[test]
2959 fn cef_rejects_dew_and_platforms_without_cef_distributions() {
2960 for platform in [
2961 TargetPlatform::MacOS,
2962 TargetPlatform::Linux,
2963 TargetPlatform::Windows,
2964 ] {
2965 assert!(
2966 ResolvedWebViewBackend::Cef
2967 .validate(platform, TargetBackend::Dew)
2968 .is_err()
2969 );
2970 }
2971 for (platform, backend) in [
2972 (TargetPlatform::Android, TargetBackend::Android),
2973 (TargetPlatform::IOS, TargetBackend::Apple),
2974 (TargetPlatform::Web, TargetBackend::Hydrolysis),
2975 ] {
2976 assert!(
2977 ResolvedWebViewBackend::Cef
2978 .validate(platform, backend)
2979 .is_err()
2980 );
2981 }
2982 }
2983
2984 #[test]
2990 #[ignore = "clones the pinned framework revision"]
2991 fn runtime_graph_is_scoped_to_the_selected_application() {
2992 let repository = crate::pinned_framework::checkout();
2993 let chromium = smol::block_on(resolve_linked_runtime_packages(
2994 repository.join("examples/chromium"),
2995 true,
2996 ))
2997 .expect("Chromium example runtime graph must resolve");
2998 assert!(
2999 chromium.contains_key("waterui-chromium"),
3000 "Chromium example graph: {chromium:#?}"
3001 );
3002 assert!(
3005 chromium.contains_key("waterui-browser-cef"),
3006 "Chromium example graph: {chromium:#?}"
3007 );
3008 assert!(
3009 !chromium.contains_key("waterui-browser-wpe"),
3010 "Chromium example graph: {chromium:#?}"
3011 );
3012 assert!(
3019 chromium.contains_key("waterui-webview"),
3020 "waterui-chromium shares the webview asset-server types: {chromium:#?}"
3021 );
3022 let chromium_features = smol::block_on(resolve_enabled_features(
3023 repository.join("examples/chromium"),
3024 true,
3025 ))
3026 .expect("Chromium example feature graph must resolve");
3027 assert!(
3028 !chromium_features.contains("webview"),
3029 "a Chromium-only application must not enable the standard WebView \
3030 component: {chromium_features:#?}"
3031 );
3032
3033 let webview = smol::block_on(resolve_linked_runtime_packages(
3034 repository.join("examples/webview"),
3035 true,
3036 ))
3037 .expect("WebView example runtime graph must resolve");
3038 assert!(
3039 webview.contains_key("waterui-webview"),
3040 "WebView example graph: {webview:#?}"
3041 );
3042 let webview_features = smol::block_on(resolve_enabled_features(
3043 repository.join("examples/webview"),
3044 true,
3045 ))
3046 .expect("WebView example feature graph must resolve");
3047 assert!(
3048 webview_features.contains("webview"),
3049 "the WebView example enables the facade `webview` feature: {webview_features:#?}"
3050 );
3051 assert!(
3052 !webview.contains_key("waterui-browser-cef"),
3053 "WebView example graph: {webview:#?}"
3054 );
3055 assert!(
3056 !webview.contains_key("waterui-chromium"),
3057 "WebView example graph: {webview:#?}"
3058 );
3059
3060 let cef_webview = smol::block_on(resolve_linked_runtime_packages(
3061 repository.join("examples/webview-cef"),
3062 true,
3063 ))
3064 .expect("CEF WebView example runtime graph must resolve");
3065 assert!(
3066 cef_webview.contains_key("waterui-browser-cef"),
3067 "CEF WebView example graph: {cef_webview:#?}"
3068 );
3069 assert!(
3070 !cef_webview.contains_key("waterui-browser-wpe"),
3071 "CEF WebView example graph: {cef_webview:#?}"
3072 );
3073 }
3074
3075 #[test]
3083 #[ignore = "clones the pinned framework revision"]
3084 fn the_map_capability_is_read_from_the_application_graph() {
3085 let repository = crate::pinned_framework::checkout();
3086
3087 let map = smol::block_on(resolve_linked_runtime_packages(
3088 repository.join("examples/map"),
3089 true,
3090 ))
3091 .expect("map example runtime graph must resolve");
3092 assert!(
3093 map.contains_key("waterui-map"),
3094 "map example graph: {map:#?}"
3095 );
3096
3097 let webview = smol::block_on(resolve_linked_runtime_packages(
3098 repository.join("examples/webview"),
3099 true,
3100 ))
3101 .expect("WebView example runtime graph must resolve");
3102 assert!(
3103 !webview.contains_key("waterui-map"),
3104 "an application that shows no map must not carry the map stack: {webview:#?}"
3105 );
3106 }
3107}
3108
3109#[cfg(test)]
3110mod scaffold_tests {
3111 use std::path::Path;
3112
3113 use super::{
3114 BundleIdentifier, CreateOptions, ManagedBackends, PackageType, Project, TargetBackend,
3115 };
3116
3117 #[test]
3122 fn create_scaffolds_the_assets_directory_declared_by_the_manifest() {
3123 let dir = tempfile::tempdir().expect("temp dir");
3124 let root = dir.path().join("water-example");
3125
3126 let project = smol::block_on(Project::create(
3127 &root,
3128 CreateOptions {
3129 name: "Water Example".to_string(),
3130 bundle_identifier: BundleIdentifier::try_from("dev.waterui.waterexample")
3131 .expect("bundle identifier"),
3132 package_type: PackageType::Playground,
3133 waterui_path: None,
3134 channel: None,
3135 framework_manifest: None,
3136 framework: Some(crate::framework::test_fixtures::stable_framework()),
3139 author: "Lexo Liu".to_string(),
3140 backends: Vec::new(),
3141 web: None,
3142 },
3143 ))
3144 .expect("project creation must succeed");
3145
3146 let assets = project.assets_dir();
3147 assert!(
3148 assets.is_dir(),
3149 "the assets root {} must exist after `water create`",
3150 assets.display()
3151 );
3152 assert_eq!(
3153 assets,
3154 root.join(project.assets_path()),
3155 "the scaffolded directory must be the one the manifest declares"
3156 );
3157 assert!(
3158 assets.join("README.md").is_file(),
3159 "a tracked file keeps the assets directory present in git"
3160 );
3161 }
3162
3163 #[test]
3168 fn create_rejects_backends_whose_packages_stable_withholds() {
3169 for backend in [
3170 TargetBackend::Gtk4,
3171 TargetBackend::WinUi,
3172 TargetBackend::Dew,
3173 ] {
3174 let dir = tempfile::tempdir().expect("temp dir");
3175 let root = dir.path().join("water-example");
3176 let error = smol::block_on(Project::create(
3177 &root,
3178 CreateOptions {
3179 name: "Water Example".to_string(),
3180 bundle_identifier: BundleIdentifier::try_from("dev.waterui.waterexample")
3181 .expect("bundle identifier"),
3182 package_type: PackageType::App,
3183 waterui_path: None,
3184 channel: None,
3185 framework_manifest: None,
3186 framework: Some(crate::framework::test_fixtures::stable_framework()),
3187 author: "Lexo Liu".to_string(),
3188 backends: vec![backend],
3189 web: None,
3190 },
3191 ))
3192 .expect_err("a withheld scaffold package must reject create");
3193 let error = error.to_string();
3194 for package in backend.scaffold_packages() {
3195 assert!(error.contains(package), "{error}");
3196 }
3197 assert!(error.contains("stable"), "{error}");
3198 assert!(error.contains("--channel dev"), "{error}");
3199 assert!(
3200 !root.exists(),
3201 "the rejection precedes any file write: {error}"
3202 );
3203 }
3204 }
3205
3206 #[test]
3211 fn shipped_binary_names_drop_the_project_root_tag() {
3212 let dir = tempfile::tempdir().expect("temp dir");
3213 let root = dir.path().join("water-example");
3214 let project = smol::block_on(Project::create(
3215 &root,
3216 CreateOptions {
3217 name: "Water Example".to_string(),
3218 bundle_identifier: BundleIdentifier::try_from("dev.waterui.waterexample")
3219 .expect("bundle identifier"),
3220 package_type: PackageType::Playground,
3221 waterui_path: None,
3222 channel: None,
3223 framework_manifest: None,
3224 framework: Some(crate::framework::test_fixtures::stable_framework()),
3225 author: "Lexo Liu".to_string(),
3226 backends: Vec::new(),
3227 web: None,
3228 },
3229 ))
3230 .expect("project creation must succeed");
3231
3232 for (shipped, tagged) in [
3233 (project.gtk4_binary_name(), project.gtk_backend_crate_name()),
3234 (
3235 project.hydrolysis_binary_name(),
3236 project.hydrolysis_backend_crate_name(),
3237 ),
3238 (
3239 project.winui_binary_name(),
3240 project.winui_backend_crate_name(),
3241 ),
3242 (
3243 project.esp32_binary_name(),
3244 project.esp32_backend_crate_name(),
3245 ),
3246 ] {
3247 assert!(
3248 tagged.as_str().starts_with(&format!("{shipped}-")),
3249 "the build name must be the shipped name plus the tag: {tagged}"
3250 );
3251 assert_eq!(
3252 tagged.as_str().len() - shipped.as_str().len(),
3253 9,
3254 "the tag is a dash plus eight hex digits: {tagged}"
3255 );
3256 }
3257 }
3258
3259 fn create_playground(root: &Path) -> Project {
3260 smol::block_on(Project::create(
3261 root,
3262 CreateOptions {
3263 name: "Water Example".to_string(),
3264 bundle_identifier: BundleIdentifier::try_from("dev.waterui.waterexample")
3265 .expect("bundle identifier"),
3266 package_type: PackageType::Playground,
3267 waterui_path: None,
3268 channel: None,
3269 framework_manifest: None,
3270 framework: Some(crate::framework::test_fixtures::stable_framework()),
3271 author: "Lexo Liu".to_string(),
3272 backends: Vec::new(),
3273 web: None,
3274 },
3275 ))
3276 .expect("project creation must succeed")
3277 }
3278
3279 #[test]
3283 fn opening_a_playground_scaffolds_only_the_platforms_managed_backend() {
3284 use crate::android::backend::AndroidBackend;
3285 use crate::apple::backend::AppleBackend;
3286 use crate::platform::TargetPlatform;
3287
3288 for (platform, apple_expected) in [
3289 (TargetPlatform::MacOS, true),
3290 (TargetPlatform::Android, false),
3291 ] {
3292 let dir = tempfile::tempdir().expect("temp dir");
3293 let root = dir.path().join("water-example");
3294 create_playground(&root);
3295
3296 let project = smol::block_on(Project::open(
3297 &root,
3298 ManagedBackends::for_platform(platform),
3299 ))
3300 .expect("opening the playground must succeed");
3301
3302 let apple_path = project.backend_path::<AppleBackend>();
3303 let android_path = project.backend_path::<AndroidBackend>();
3304 assert_eq!(
3305 project.apple_backend().is_some(),
3306 apple_expected,
3307 "{platform:?}: apple backend"
3308 );
3309 assert_eq!(
3310 project.android_backend().is_some(),
3311 !apple_expected,
3312 "{platform:?}: android backend"
3313 );
3314 assert_eq!(
3315 apple_path.exists(),
3316 apple_expected,
3317 "{platform:?}: {}",
3318 apple_path.display()
3319 );
3320 assert_eq!(
3321 android_path.exists(),
3322 !apple_expected,
3323 "{platform:?}: {}",
3324 android_path.display()
3325 );
3326 }
3327 }
3328
3329 #[test]
3335 fn same_named_projects_stage_packaged_binaries_under_their_own_backends() {
3336 let dir = tempfile::tempdir().expect("temp dir");
3337 let create = |root: &Path| {
3338 smol::block_on(Project::create(
3339 root,
3340 CreateOptions {
3341 name: "Water Example".to_string(),
3342 bundle_identifier: BundleIdentifier::try_from("dev.waterui.waterexample")
3343 .expect("bundle identifier"),
3344 package_type: PackageType::App,
3345 waterui_path: None,
3346 channel: None,
3347 framework_manifest: None,
3348 framework: Some(crate::framework::test_fixtures::stable_framework()),
3349 author: "Lexo Liu".to_string(),
3350 backends: Vec::new(),
3351 web: None,
3352 },
3353 ))
3354 .expect("project creation must succeed")
3355 };
3356 let first = create(&dir.path().join("one/demo"));
3357 let second = create(&dir.path().join("two/demo"));
3358
3359 let staged = |project: &Project| {
3360 crate::platforming::packaging::dist_dir(
3361 &project.backend_path::<crate::hydrolysis::backend::HydrolysisBackend>(),
3362 "linux",
3363 Some("release"),
3364 )
3365 .join(project.hydrolysis_binary_name().as_str())
3366 };
3367 let first_staged = staged(&first);
3368 let second_staged = staged(&second);
3369
3370 assert_ne!(
3371 first_staged, second_staged,
3372 "same-named projects must not stage the same shipped path"
3373 );
3374 for (project, staged) in [(&first, &first_staged), (&second, &second_staged)] {
3375 assert!(
3376 staged.starts_with(
3377 project.backend_path::<crate::hydrolysis::backend::HydrolysisBackend>()
3378 ),
3379 "{} must live under the project's own managed backend directory",
3380 staged.display()
3381 );
3382 }
3383 }
3384}
3385
3386#[cfg(test)]
3387mod local_patch_tests {
3388 use std::path::Path;
3389
3390 use super::Project;
3391
3392 #[test]
3396 fn a_local_checkout_project_follows_the_checkouts_patch_tables() {
3397 let directory = tempfile::tempdir().expect("temp dir");
3398 let checkout = directory.path().join("waterui");
3399 std::fs::create_dir_all(&checkout).expect("checkout dir");
3400 std::fs::write(
3401 checkout.join("Cargo.toml"),
3402 include_str!("../../tests/fixtures/local_checkout_patches.toml"),
3403 )
3404 .expect("checkout manifest");
3405 let app = directory.path().join("app");
3406 std::fs::create_dir_all(&app).expect("project dir");
3407 let cargo_path = app.join("Cargo.toml");
3408 std::fs::write(
3409 &cargo_path,
3410 "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nwaterui = { path = \"../waterui\" }\n\n[patch.crates-io]\nwaterui-core = { path = \"../elsewhere/core\" }\nstale = { path = \"../elsewhere/stale\" }\n",
3411 )
3412 .expect("project manifest");
3413
3414 smol::block_on(Project::refresh_local_patches(
3415 &app,
3416 Path::new("../waterui"),
3417 ))
3418 .expect("tables refresh");
3419 let refreshed = std::fs::read_to_string(&cargo_path).expect("refreshed manifest");
3420 let manifest = cargo_toml::Manifest::from_str(&refreshed).expect("manifest parses");
3421 let crates_io = &manifest.patch["crates-io"];
3422 let cargo_toml::Dependency::Detailed(core) = &crates_io["waterui-core"] else {
3423 panic!("the core patch is a path dependency");
3424 };
3425 assert_eq!(core.path.as_deref(), Some("../waterui/core"));
3426 assert!(!crates_io.contains_key("stale"));
3427 assert!(crates_io.contains_key("vello"));
3428 assert!(refreshed.starts_with("[package]"));
3429
3430 smol::block_on(Project::refresh_local_patches(
3431 &app,
3432 Path::new("../waterui"),
3433 ))
3434 .expect("second refresh");
3435 assert_eq!(
3436 std::fs::read_to_string(&cargo_path).expect("manifest after the second refresh"),
3437 refreshed
3438 );
3439 }
3440
3441 #[test]
3446 fn a_member_of_the_checkouts_workspace_keeps_its_manifest() {
3447 let directory = tempfile::tempdir().expect("temp dir");
3448 let checkout = directory.path().join("waterui");
3449 let app = checkout.join("examples/app");
3450 std::fs::create_dir_all(&app).expect("project dir");
3451 std::fs::write(
3452 checkout.join("Cargo.toml"),
3453 include_str!("../../tests/fixtures/local_checkout_patches.toml"),
3454 )
3455 .expect("checkout manifest");
3456 let manifest = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nwaterui = { path = \"../..\" }\n";
3457 let cargo_path = app.join("Cargo.toml");
3458 std::fs::write(&cargo_path, manifest).expect("project manifest");
3459
3460 smol::block_on(Project::refresh_local_patches(&app, Path::new("../..")))
3461 .expect("tables refresh");
3462
3463 assert_eq!(
3464 std::fs::read_to_string(&cargo_path).expect("manifest after the refresh"),
3465 manifest
3466 );
3467 }
3468
3469 #[test]
3473 fn a_member_of_a_foreign_workspace_is_told_where_the_tables_belong() {
3474 let directory = tempfile::tempdir().expect("temp dir");
3475 let checkout = directory.path().join("waterui");
3476 std::fs::create_dir_all(&checkout).expect("checkout dir");
3477 std::fs::write(
3478 checkout.join("Cargo.toml"),
3479 include_str!("../../tests/fixtures/local_checkout_patches.toml"),
3480 )
3481 .expect("checkout manifest");
3482 let workspace = directory.path().join("their-workspace");
3483 let app = workspace.join("app");
3484 std::fs::create_dir_all(&app).expect("project dir");
3485 std::fs::write(
3486 workspace.join("Cargo.toml"),
3487 "[workspace]\nmembers = [\"app\"]\n",
3488 )
3489 .expect("workspace manifest");
3490 let manifest = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nwaterui = { path = \"../../waterui\" }\n";
3491 let cargo_path = app.join("Cargo.toml");
3492 std::fs::write(&cargo_path, manifest).expect("project manifest");
3493
3494 let error = smol::block_on(Project::refresh_local_patches(
3495 &app,
3496 Path::new("../../waterui"),
3497 ))
3498 .expect_err("a copy here would be ignored");
3499
3500 let message = error.to_string();
3501 assert!(message.contains("their-workspace"), "{message}");
3502 assert_eq!(
3503 std::fs::read_to_string(&cargo_path).expect("manifest after the refusal"),
3504 manifest
3505 );
3506 }
3507}