1use cargo_toml::Manifest as CargoManifest;
4use futures_util::FutureExt as _;
5use futures_util::future::{BoxFuture, Shared};
6use tracing::info;
7
8use crate::build::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)]
24struct CargoLayout {
25 target_dir: PathBuf,
26 workspace_root: PathBuf,
29}
30
31enum CargoResolution {
32 Local,
33 Locked,
34 Update,
35}
36
37fn spawn_cargo_layout_resolution(
38 current_dir: &Path,
39 framework: Option<ResolvedFramework>,
40 local: bool,
41) -> Shared<BoxFuture<'static, Result<CargoLayout, String>>> {
42 let current_dir = current_dir.to_path_buf();
43 let mode = if local {
44 CargoResolution::Local
45 } else if framework.is_some() {
46 CargoResolution::Locked
47 } else {
48 CargoResolution::Update
49 };
50 smol::spawn(async move {
51 resolve_cargo_layout(¤t_dir, framework, mode)
52 .await
53 .map_err(|error| error.to_string())
54 })
55 .boxed()
56 .shared()
57}
58
59#[derive(Debug, Clone)]
61pub struct Project {
62 root: PathBuf,
63 manifest: Manifest,
64 crate_name: CrateName,
65 cargo_layout: Shared<BoxFuture<'static, Result<CargoLayout, String>>>,
66 linked_packages: Arc<async_lock::OnceCell<Result<BTreeMap<String, String>, String>>>,
67 enabled_features: Arc<async_lock::OnceCell<Result<BTreeSet<String>, String>>>,
68 managed_backends_root: PathBuf,
69}
70
71impl Project {
72 pub async fn select_channel(
77 path: impl AsRef<Path>,
78 channel: FrameworkChannel,
79 ) -> eyre::Result<Self> {
80 let path = smol::fs::canonicalize(path.as_ref()).await?;
81 let water_path = path.join("Water.toml");
82 let cargo_path = path.join("Cargo.toml");
83 let mut water: toml_edit::DocumentMut =
84 smol::fs::read_to_string(&water_path).await?.parse()?;
85 let previous: Manifest = toml::from_str(&water.to_string())?;
86 let mut cargo: toml_edit::DocumentMut =
87 smol::fs::read_to_string(&cargo_path).await?.parse()?;
88 let crate_name = CrateName::try_from(
89 cargo["package"]["name"]
90 .as_str()
91 .ok_or_else(|| eyre::eyre!("channel selection requires a project Cargo.toml"))?,
92 )
93 .map_err(|error| eyre::eyre!(error))?;
94 let (framework, lockfile) = ResolvedFramework::resolve(channel).await?;
95 let mut next = previous.clone();
96 next.waterui_path = None;
97 next.framework = Some(framework.clone());
98 let mut updates =
99 templates::framework_updates(&path, &previous, &next, &crate_name).await?;
100 framework.update_manifest(&mut cargo, &templates::project_patches(&path, &previous)?)?;
101 water.remove("waterui_path");
102 water["framework"] =
103 toml_edit::Item::Table(toml_edit::ser::to_document(&framework)?.into_table());
104 updates.push((water_path, water.to_string().into_bytes()));
105 updates.push((cargo_path, cargo.to_string().into_bytes()));
106 if let Some(lockfile) = lockfile {
107 updates.push((
108 path.join("Cargo.lock"),
109 framework.cargo_lock(&lockfile)?.to_string().into_bytes(),
110 ));
111 updates.push((path.join("Water.lock"), lockfile));
112 }
113 let mut updates: Vec<_> = updates
114 .into_iter()
115 .map(|(file, contents)| (file, Some(contents)))
116 .collect();
117 if channel == FrameworkChannel::Stable
118 && previous
119 .framework
120 .as_ref()
121 .is_some_and(|previous| previous.channel() != Some(FrameworkChannel::Stable))
122 {
123 updates.push((path.join("Water.lock"), None));
124 }
125 apply_channel_selection(&path, framework, updates).await?;
126 Self::open_for_preview_build(path).await.map_err(Into::into)
127 }
128
129 pub async fn run<B: Backend, D: Device>(
141 &self,
142 backend: &B,
143 platform: TargetPlatform,
144 device: D,
145 ) -> Result<Running, FailToRun> {
146 self.run_with_options(backend, platform, device, RunOptions::new())
147 .await
148 }
149
150 pub async fn run_with_options<B: Backend, D: Device>(
157 &self,
158 backend: &B,
159 platform: TargetPlatform,
160 device: D,
161 run_options: RunOptions,
162 ) -> Result<Running, FailToRun> {
163 backend
165 .build(
166 self,
167 platform,
168 BuildOptions::development(BuildProfile::Debug),
169 )
170 .await
171 .map_err(FailToRun::Build)?;
172
173 let artifact = backend
175 .package(self, platform, PackageOptions::development())
176 .await
177 .map_err(FailToRun::Package)?;
178
179 Self::run_packaged(device, artifact, run_options).await
180 }
181
182 pub async fn run_android_with_options<D: Device + AndroidAbiProvider>(
190 &self,
191 _backend: &AndroidBackend,
192 device: D,
193 run_options: RunOptions,
194 ) -> Result<Running, FailToRun> {
195 let abi = device.android_abi();
196
197 self.browser_runtime_plan(TargetPlatform::Android, TargetBackend::Android)
198 .await
199 .map_err(FailToRun::Build)?;
200
201 AndroidPlatform::clean_jni_libs(self)
202 .await
203 .map_err(FailToRun::Build)?;
204
205 AndroidPlatform::new(abi)
206 .build(self, BuildOptions::development(BuildProfile::Debug))
207 .await
208 .map_err(FailToRun::Build)?;
209
210 let artifact =
211 AndroidPlatform::package_with_abis(self, PackageOptions::development(), &[abi])
212 .await
213 .map_err(FailToRun::Package)?;
214
215 Self::run_packaged(device, artifact, run_options).await
216 }
217
218 async fn run_packaged<D: Device>(
219 device: D,
220 artifact: Artifact,
221 run_options: RunOptions,
222 ) -> Result<Running, FailToRun> {
223 info!("Running on device");
224
225 let running = device
226 .run(&crate::toolchain::Host::current(), artifact, run_options)
227 .await?;
228 Ok(running)
229 }
230
231 #[must_use]
235 pub fn root(&self) -> &Path {
236 &self.root
237 }
238
239 pub async fn target_dir(&self) -> eyre::Result<PathBuf> {
245 Ok(self.cargo_layout().await?.target_dir)
246 }
247
248 pub async fn lockfile_path(&self) -> eyre::Result<PathBuf> {
258 Ok(self.cargo_layout().await?.workspace_root.join("Cargo.lock"))
259 }
260
261 async fn cargo_layout(&self) -> eyre::Result<CargoLayout> {
262 self.cargo_layout
263 .clone()
264 .await
265 .map_err(|error| eyre::eyre!(error))
266 }
267
268 pub async fn water_target_dir(&self, linkage: RustLinkage) -> eyre::Result<PathBuf> {
297 let variant = match linkage {
298 RustLinkage::SharedRuntime => "shared",
299 RustLinkage::Static => "static",
300 };
301 Ok(self
302 .target_dir()
303 .await?
304 .join("water-backends")
305 .join(variant))
306 }
307
308 pub async fn toolchain_target_dir(&self, toolchain: &str) -> eyre::Result<PathBuf> {
319 Ok(self
320 .target_dir()
321 .await?
322 .join("water-backends")
323 .join(toolchain))
324 }
325
326 #[must_use]
328 pub const fn backends(&self) -> &Backends {
329 &self.manifest.backends
330 }
331
332 #[must_use]
334 pub const fn crate_name(&self) -> &CrateName {
335 &self.crate_name
336 }
337
338 #[must_use]
340 pub fn ffi_crate_name(&self) -> CrateName {
341 self.app_crate_overrides()
342 .and_then(|crates| crates.ffi.clone())
343 .unwrap_or_else(|| self.crate_name.with_suffix("ffi"))
344 }
345
346 #[must_use]
348 pub fn preview_ffi_crate_name(&self) -> CrateName {
349 self.crate_name.with_suffix("preview-ffi")
350 }
351
352 #[must_use]
354 pub fn preview_dylib_crate_path(&self, workspace_root: &Path) -> PathBuf {
355 self.preview_ffi_crate_path(workspace_root)
356 }
357
358 #[must_use]
360 pub fn preview_dylib_crate_name(&self) -> CrateName {
361 self.preview_ffi_crate_name()
362 }
363
364 #[must_use]
366 pub fn gtk_backend_crate_name(&self) -> CrateName {
367 self.app_crate_overrides()
368 .and_then(|crates| crates.gtk.clone())
369 .unwrap_or_else(|| self.crate_name.with_suffix("gtk4"))
370 }
371
372 #[must_use]
374 pub fn hydrolysis_backend_crate_name(&self) -> CrateName {
375 self.app_crate_overrides()
376 .and_then(|crates| crates.hydrolysis.clone())
377 .unwrap_or_else(|| self.crate_name.with_suffix("hydrolysis"))
378 }
379
380 #[must_use]
382 pub fn esp32_backend_crate_name(&self) -> CrateName {
383 self.crate_name.with_suffix("esp32")
384 }
385
386 #[must_use]
388 pub fn tui_backend_crate_name(&self) -> CrateName {
389 self.crate_name.with_suffix("tui")
390 }
391
392 #[must_use]
394 pub const fn package_type(&self) -> PackageType {
395 self.manifest.package.package_type
396 }
397
398 #[must_use]
400 pub fn is_playground(&self) -> bool {
401 self.package_type() == PackageType::Playground
402 }
403
404 #[must_use]
406 pub const fn apple_backend(&self) -> Option<&AppleBackend> {
407 self.manifest.backends.apple()
408 }
409
410 #[must_use]
414 pub fn backend_path<B: Backend>(&self) -> PathBuf {
415 self.managed_backends_root.join(B::DEFAULT_PATH)
416 }
417
418 #[must_use]
422 pub fn backend_relative_path<B: Backend>(&self) -> PathBuf {
423 self.manifest.backends.path().join(B::DEFAULT_PATH)
424 }
425
426 #[must_use]
428 pub fn ffi_crate_path(&self) -> PathBuf {
429 self.managed_backends_root.join("ffi")
430 }
431
432 #[must_use]
434 pub fn preview_module_member_path(&self) -> PathBuf {
435 Path::new(crate::templates::PREVIEW_MODULES_DIR)
436 .join(self.preview_ffi_crate_name().to_string())
437 }
438
439 #[must_use]
446 pub fn preview_ffi_crate_path(&self, workspace_root: &Path) -> PathBuf {
447 workspace_root.join(self.preview_module_member_path())
448 }
449
450 #[must_use]
452 pub fn ffi_crate_relative_path(&self) -> PathBuf {
453 self.manifest.backends.path().join("ffi")
454 }
455
456 #[must_use]
458 pub const fn android_backend(&self) -> Option<&AndroidBackend> {
459 self.manifest.backends.android()
460 }
461
462 #[must_use]
464 pub const fn gtk4_backend(&self) -> Option<&crate::gtk4::backend::Gtk4Backend> {
465 self.manifest.backends.gtk4()
466 }
467
468 #[must_use]
470 pub const fn hydrolysis_backend(
471 &self,
472 ) -> Option<&crate::hydrolysis::backend::HydrolysisBackend> {
473 self.manifest.backends.hydrolysis()
474 }
475
476 #[must_use]
478 pub const fn esp32_backend(&self) -> Option<&crate::esp32::backend::Esp32Backend> {
479 self.manifest.backends.esp32()
480 }
481
482 #[must_use]
484 pub const fn manifest(&self) -> &Manifest {
485 &self.manifest
486 }
487
488 pub async fn resolved_framework(&self) -> eyre::Result<ResolvedFramework> {
497 ResolvedFramework::for_manifest(self.manifest(), &self.root).await
498 }
499
500 pub async fn links_runtime_package(&self, package_name: &str) -> eyre::Result<bool> {
512 let project_root = self.root.clone();
513 let cargo_layout = self.cargo_layout.clone();
514 let packages = self
515 .linked_packages
516 .get_or_init(|| async move {
517 cargo_layout.await?;
518 resolve_linked_runtime_packages(project_root)
519 .await
520 .map_err(|error| error.to_string())
521 })
522 .await;
523 match packages {
524 Ok(packages) => Ok(packages.contains_key(package_name)),
525 Err(error) => Err(eyre::eyre!(error.clone())),
526 }
527 }
528
529 pub async fn uses_standard_webview(&self) -> eyre::Result<bool> {
544 let project_root = self.root.clone();
545 let cargo_layout = self.cargo_layout.clone();
546 let features = self
547 .enabled_features
548 .get_or_init(|| async move {
549 cargo_layout.await?;
550 resolve_enabled_features(project_root)
551 .await
552 .map_err(|error| error.to_string())
553 })
554 .await;
555 match features {
556 Ok(features) => Ok(features.contains("webview")),
557 Err(error) => Err(eyre::eyre!(error.clone())),
558 }
559 }
560
561 pub async fn resolved_webview_backend(
580 &self,
581 platform: TargetPlatform,
582 backend: TargetBackend,
583 ) -> eyre::Result<Option<ResolvedWebViewBackend>> {
584 if !self.uses_standard_webview().await? {
585 return Ok(None);
586 }
587 let engine = self.linked_browser_engine().await?;
588 engine
589 .unwrap_or(ResolvedWebViewBackend::System)
590 .validate(platform, backend)
591 .map(Some)
592 .map_err(Into::into)
593 }
594
595 pub async fn linked_browser_engine(&self) -> eyre::Result<Option<ResolvedWebViewBackend>> {
603 let cef = self.links_runtime_package("waterui-browser-cef").await?;
604 let wpe = self.links_runtime_package("waterui-browser-wpe").await?;
605 match (cef, wpe) {
606 (true, true) => eyre::bail!(
607 "the application links both waterui-browser-cef and waterui-browser-wpe; \
608 exactly one browser engine can draw a WebView"
609 ),
610 (true, false) => Ok(Some(ResolvedWebViewBackend::Cef)),
611 (false, true) => Ok(Some(ResolvedWebViewBackend::Wpe)),
612 (false, false) => Ok(None),
613 }
614 }
615
616 pub async fn browser_runtime_plan(
623 &self,
624 platform: TargetPlatform,
625 backend: TargetBackend,
626 ) -> eyre::Result<BrowserRuntimePlan> {
627 let webview = self.resolved_webview_backend(platform, backend).await?;
628 let chromium = self.links_runtime_package("waterui-chromium").await?;
629 if chromium && !cef_is_supported(platform, backend) {
630 eyre::bail!(
631 "waterui-chromium requires CEF, which is unsupported for platform {platform:?} \
632 with backend {backend:?}"
633 );
634 }
635 Ok(BrowserRuntimePlan { webview, chromium })
636 }
637
638 #[must_use]
640 pub const fn bundle_identifier(&self) -> &BundleIdentifier {
641 &self.manifest.package.bundle_identifier
642 }
643
644 #[must_use]
646 pub fn assets_path(&self) -> &str {
647 &self.manifest.package.assets_path
648 }
649
650 #[must_use]
652 pub fn assets_dir(&self) -> PathBuf {
653 self.root.join(&self.manifest.package.assets_path)
654 }
655
656 pub async fn clean<B: Backend>(
662 &self,
663 backend: &B,
664 platform: TargetPlatform,
665 ) -> Result<(), eyre::Report> {
666 backend.clean(self, platform).await
667 }
668
669 pub async fn clean_all(&self) -> Result<(), eyre::Report> {
681 use crate::{
682 android::platform::clean_android, apple::platform::clean_apple,
683 esp32::platform::clean_esp32, gtk4::platform::clean_gtk4,
684 hydrolysis::platform::clean_hydrolysis,
685 };
686
687 if self.is_playground() {
688 crate::water_dir::remove_project_build_cache(self.root()).await?;
689 let water_backends_root = self.target_dir().await?.join("water-backends");
694 if water_backends_root.exists() {
695 smol::fs::remove_dir_all(&water_backends_root).await?;
696 }
697 return Ok(());
698 }
699
700 let target_dir = self.target_dir().await?;
702 if target_dir.exists() {
703 smol::fs::remove_dir_all(&target_dir).await?;
704 }
705
706 if self.apple_backend().is_some() {
708 clean_apple(self).await?;
709 }
710
711 if self.android_backend().is_some() {
713 clean_android(self).await?;
714 }
715
716 if self.gtk4_backend().is_some() || (self.is_playground() && cfg!(target_os = "linux")) {
718 clean_gtk4(self).await?;
719 }
720
721 if self.hydrolysis_backend().is_some() || self.is_playground() {
723 clean_hydrolysis(self).await?;
724 }
725
726 if self.esp32_backend().is_some() {
728 clean_esp32(self).await?;
729 }
730
731 let ffi_target_dir = self.ffi_crate_path().join("target");
732 if ffi_target_dir.exists() {
733 smol::fs::remove_dir_all(&ffi_target_dir).await?;
734 }
735
736 Ok(())
737 }
738
739 pub async fn package<B: Backend>(
745 &self,
746 backend: &B,
747 platform: TargetPlatform,
748 options: PackageOptions,
749 ) -> Result<Artifact, eyre::Report> {
750 backend.package(self, platform, options).await
751 }
752
753 fn app_crate_overrides(&self) -> Option<&AppCrates> {
754 self.manifest.app.as_ref()?.crates.as_ref()
755 }
756}
757
758#[derive(Debug, thiserror::Error)]
760pub enum FailToOpenProject {
761 #[error("Failed to open project manifest: {0}")]
763 Manifest(FailToOpenManifest),
764 #[error("Failed to read Cargo.toml: {0}")]
766 CargoManifest(cargo_toml::Error),
767
768 #[error("Failed to get Cargo metadata: {0}")]
770 TargetDirError(#[from] cargo_metadata::Error),
771
772 #[error("Framework compatibility check failed: {0}")]
774 Framework(eyre::Report),
775 #[error("Failed to refresh the [patch] tables from the local checkout: {0}")]
778 LocalPatches(eyre::Report),
779
780 #[error("Invalid Cargo.toml: missing crate name")]
782 MissingCrateName,
783
784 #[error("Invalid Cargo.toml crate name: {0}")]
786 InvalidCrateName(String),
787
788 #[error("Project permissions are not allowed in non-playground projects")]
790 PermissionsNotAllowedInNonPlayground,
791
792 #[error(
794 "Backend project configuration is not allowed in playground projects \
795 (device settings under [backends.esp32] are the exception)"
796 )]
797 BackendsNotAllowedInPlayground,
798
799 #[error("Failed to initialize backend: {0}")]
801 BackendInit(#[from] crate::backend::FailToInitBackend),
802
803 #[error("Failed to prepare managed build cache: {0}")]
805 BuildCache(#[from] eyre::Report),
806}
807
808#[derive(Debug, thiserror::Error)]
810pub enum FailToCreateProject {
811 #[error("Failed to resolve framework: {0}")]
813 Framework(eyre::Report),
814 #[error("Directory already exists: {0}")]
816 DirectoryExists(PathBuf),
817 #[error("{0} is already a WaterUI project (Water.toml exists)")]
819 AlreadyProject(PathBuf),
820 #[error(
823 "{0} already contains a Cargo.toml; merge the generated scaffold manually or remove it first"
824 )]
825 CargoManifestExists(PathBuf),
826 #[error("Failed to create directory: {0}")]
828 CreateDir(std::io::Error),
829 #[error("Failed to scaffold project: {0}")]
831 Scaffold(std::io::Error),
832 #[error("Failed to save manifest: {0}")]
834 SaveManifest(#[from] FailToSaveManifest),
835
836 #[error("Failed to get Cargo metadata: {0}")]
838 TargetDirError(#[from] cargo_metadata::Error),
839
840 #[error("Failed to resolve managed build cache: {0}")]
842 BuildCache(#[from] eyre::Report),
843
844 #[error("Failed to initialize git repository: {0}")]
846 GitInit(std::io::Error),
847 #[error("Failed to check git repository status: {0}")]
849 GitStatus(std::io::Error),
850}
851
852#[derive(Debug, Clone)]
854pub struct CreateOptions {
855 pub name: String,
857 pub bundle_identifier: BundleIdentifier,
859 pub package_type: PackageType,
861 pub waterui_path: Option<PathBuf>,
863 pub channel: Option<FrameworkChannel>,
866 pub framework_manifest: Option<PathBuf>,
870 pub framework: Option<ResolvedFramework>,
874 pub author: String,
876 pub web: Option<WebScaffold>,
879}
880
881#[derive(Debug, Clone)]
883pub struct WebScaffold {
884 pub package_manager: web::PackageManager,
886 pub include_arg: String,
889}
890
891impl CreateOptions {
892 fn crate_name(&self) -> Result<CrateName, FailToCreateProject> {
893 let name = self
894 .name
895 .chars()
896 .map(|character| {
897 if character.is_alphanumeric() {
898 character.to_ascii_lowercase()
899 } else {
900 '_'
901 }
902 })
903 .collect::<String>();
904 CrateName::try_from(name).map_err(|error| {
905 FailToCreateProject::Scaffold(std::io::Error::new(
906 std::io::ErrorKind::InvalidInput,
907 error,
908 ))
909 })
910 }
911
912 async fn resolve_framework(&mut self) -> eyre::Result<(ResolvedFramework, Option<Vec<u8>>)> {
918 let selected = [
919 self.waterui_path.is_some(),
920 self.channel.is_some(),
921 self.framework_manifest.is_some(),
922 self.framework.is_some(),
923 ]
924 .into_iter()
925 .filter(|selected| *selected)
926 .count();
927 if selected > 1 {
928 eyre::bail!(
929 "a framework channel, a local source path, a framework manifest, \
930 and a resolved framework are mutually exclusive"
931 );
932 }
933 if let Some(path) = &self.waterui_path {
934 let root = smol::fs::canonicalize(path).await?;
935 self.waterui_path = Some(root.clone());
936 return Ok((ResolvedFramework::for_local_checkout(&root).await?, None));
937 }
938 if let Some(path) = &self.framework_manifest {
939 return ResolvedFramework::resolve_manifest(path).await;
940 }
941 if let Some(framework) = &self.framework {
942 return Ok((framework.clone(), None));
943 }
944 ResolvedFramework::resolve(self.channel.unwrap_or_default()).await
945 }
946}
947
948impl Project {
949 async fn scaffold_ffi_companion(&self) -> Result<(), crate::backend::FailToInitBackend> {
950 let manifest = self.manifest();
951 let app_name = manifest
952 .package
953 .name
954 .chars()
955 .filter(|c| c.is_alphanumeric())
956 .collect::<String>();
957 let webview_enabled = self
958 .uses_standard_webview()
959 .await
960 .map_err(crate::backend::FailToInitBackend::Config)?;
961 let chromium_enabled = self
962 .links_runtime_package("waterui-chromium")
963 .await
964 .map_err(crate::backend::FailToInitBackend::Config)?;
965 let browser_engine = self
966 .linked_browser_engine()
967 .await
968 .map_err(crate::backend::FailToInitBackend::Config)?;
969 let framework = self
970 .resolved_framework()
971 .await
972 .map_err(crate::backend::FailToInitBackend::Config)?;
973 let ctx = TemplateContext::for_project_manifest(
974 manifest,
975 self.crate_name().clone(),
976 app_name,
977 &framework,
978 )
979 .with_backend_project_path(self.ffi_crate_path())
980 .with_project_root_path(self.root.clone())
981 .with_webview_enabled(webview_enabled)
982 .with_chromium_enabled(chromium_enabled)
983 .with_browser_engine(browser_engine);
984
985 templates::ffi::scaffold(&self.ffi_crate_path(), &ctx, &self.ffi_crate_name())
986 .await
987 .map_err(crate::backend::FailToInitBackend::Io)?;
988
989 let lockfile = self
990 .lockfile_path()
991 .await
992 .map_err(crate::backend::FailToInitBackend::Config)?;
993 templates::ffi::seed_lockfile(&self.ffi_crate_path(), &lockfile)
994 .await
995 .map_err(crate::backend::FailToInitBackend::Io)
996 }
997
998 pub async fn scaffold_preview_ffi_companion(
1004 &self,
1005 workspace_root: &Path,
1006 ) -> Result<PathBuf, crate::backend::FailToInitBackend> {
1007 let manifest = self.manifest();
1008 let app_name = manifest
1009 .package
1010 .name
1011 .chars()
1012 .filter(|c| c.is_alphanumeric())
1013 .collect::<String>();
1014 let framework = self
1015 .resolved_framework()
1016 .await
1017 .map_err(crate::backend::FailToInitBackend::Config)?;
1018 let ctx = TemplateContext::for_project_manifest(
1019 manifest,
1020 self.crate_name().clone(),
1021 app_name,
1022 &framework,
1023 )
1024 .with_backend_project_path(self.preview_ffi_crate_path(workspace_root))
1025 .with_project_root_path(self.root.clone());
1026
1027 let crate_path = self.preview_ffi_crate_path(workspace_root);
1028 templates::preview_ffi::scaffold(&crate_path, &ctx, &self.preview_ffi_crate_name())
1029 .await
1030 .map_err(crate::backend::FailToInitBackend::Io)?;
1031 Ok(crate_path)
1032 }
1033
1034 async fn remove_ffi_companion_if_unused(&self) -> eyre::Result<()> {
1035 if self.apple_backend().is_some() || self.android_backend().is_some() {
1036 return Ok(());
1037 }
1038
1039 let ffi_path = self.ffi_crate_path();
1040 if ffi_path.exists() {
1041 smol::fs::remove_dir_all(&ffi_path).await?;
1042 }
1043
1044 Ok(())
1045 }
1046
1047 pub async fn create(
1059 path: impl AsRef<Path>,
1060 options: CreateOptions,
1061 ) -> Result<Self, FailToCreateProject> {
1062 let path = path.as_ref().to_path_buf();
1063
1064 if path.exists() {
1066 return Err(FailToCreateProject::DirectoryExists(path));
1067 }
1068
1069 Self::scaffold_project(path, options).await
1070 }
1071
1072 pub async fn init(
1082 path: impl AsRef<Path>,
1083 options: CreateOptions,
1084 ) -> Result<Self, FailToCreateProject> {
1085 let path = path.as_ref().to_path_buf();
1086 if path.join("Water.toml").exists() {
1087 return Err(FailToCreateProject::AlreadyProject(path));
1088 }
1089 if path.join("Cargo.toml").exists() {
1090 return Err(FailToCreateProject::CargoManifestExists(path));
1091 }
1092 Self::scaffold_project(path, options).await
1093 }
1094
1095 async fn scaffold_project(
1096 path: PathBuf,
1097 mut options: CreateOptions,
1098 ) -> Result<Self, FailToCreateProject> {
1099 let crate_name = options.crate_name()?;
1101 let (framework, lockfile) = options
1102 .resolve_framework()
1103 .await
1104 .map_err(FailToCreateProject::Framework)?;
1105
1106 smol::fs::create_dir_all(&path)
1110 .await
1111 .map_err(FailToCreateProject::CreateDir)?;
1112
1113 let ctx = TemplateContext::for_create_options(&options, crate_name.clone(), &framework);
1115
1116 let assets_path = default_assets_path();
1119
1120 templates::root::scaffold(&path, &ctx, &assets_path)
1122 .await
1123 .map_err(FailToCreateProject::Scaffold)?;
1124
1125 crate::mcp::ensure_mcp_json(&path)
1128 .await
1129 .map_err(FailToCreateProject::Scaffold)?;
1130 if let Some(lockfile) = lockfile {
1131 let contents = ctx
1132 .framework
1133 .cargo_lock(&lockfile)
1134 .map_err(FailToCreateProject::Framework)?
1135 .to_string();
1136 smol::fs::write(path.join("Water.lock"), lockfile)
1137 .await
1138 .map_err(FailToCreateProject::Scaffold)?;
1139 smol::fs::write(path.join("Cargo.lock"), contents)
1140 .await
1141 .map_err(FailToCreateProject::Scaffold)?;
1142 }
1143
1144 let mut backends = Backends::default();
1146 if options.package_type == PackageType::App {
1147 backends.set_path("backends");
1148 }
1149
1150 let manifest = Manifest {
1151 package: Package {
1152 package_type: options.package_type,
1153 name: options.name.clone(),
1154 bundle_identifier: options.bundle_identifier.clone(),
1155 assets_path,
1156 accessory: false,
1157 },
1158 backends,
1159 waterui_path: options
1160 .waterui_path
1161 .as_ref()
1162 .map(|p| p.display().to_string()),
1163 framework: framework.channel().is_some().then_some(framework),
1166 permissions: BTreeMap::default(),
1167 app: None,
1168 theme: None,
1169 launch: None,
1170 web: options.web.as_ref().map(|scaffold| web::WebConfig {
1171 package_manager: scaffold.package_manager,
1172 }),
1173 };
1174
1175 manifest.save(&path).await?;
1177
1178 Self::ensure_git_init(&path).await?;
1180
1181 let managed_backends_root = if options.package_type == PackageType::Playground {
1182 crate::water_dir::project_build_cache_dir(&path)
1183 .await
1184 .map_err(FailToCreateProject::BuildCache)?
1185 } else {
1186 path.join(manifest.backends.path())
1187 };
1188
1189 let cargo_layout = if let Some(framework) = &manifest.framework {
1190 let layout =
1191 resolve_cargo_layout(&path, Some(framework.clone()), CargoResolution::Update)
1192 .await
1193 .map_err(FailToCreateProject::Framework)?;
1194 futures_util::future::ready(Ok::<CargoLayout, String>(layout))
1195 .boxed()
1196 .shared()
1197 } else {
1198 spawn_cargo_layout_resolution(&path, None, true)
1199 };
1200 Ok(Self {
1201 root: path,
1202 manifest,
1203 crate_name,
1204 cargo_layout,
1205 linked_packages: Arc::new(async_lock::OnceCell::new()),
1206 enabled_features: Arc::new(async_lock::OnceCell::new()),
1207 managed_backends_root,
1208 })
1209 }
1210
1211 async fn ensure_git_init(path: &Path) -> Result<(), FailToCreateProject> {
1216 let mut cmd = Command::new("git");
1219
1220 let is_in_git = command(&mut cmd)
1221 .args(["rev-parse", "--git-dir"])
1222 .current_dir(path)
1223 .output()
1224 .await
1225 .map_err(FailToCreateProject::GitStatus)?
1226 .status
1227 .success();
1228
1229 if !is_in_git {
1230 let mut cmd = Command::new("git");
1232 command(&mut cmd)
1233 .args(["init"])
1234 .current_dir(path)
1235 .status()
1236 .await
1237 .map_err(FailToCreateProject::GitInit)?;
1238 }
1239
1240 Ok(())
1241 }
1242
1243 pub async fn init_apple_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1250 use crate::backend::Backend;
1251
1252 let backend = AppleBackend::init(self).await?;
1253 self.scaffold_ffi_companion().await?;
1254 self.manifest.backends.set_apple(backend);
1255 self.manifest
1256 .save(&self.root)
1257 .await
1258 .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1259 Ok(())
1260 }
1261
1262 pub async fn init_android_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1269 use crate::backend::Backend;
1270
1271 let backend = AndroidBackend::init(self).await?;
1272 self.scaffold_ffi_companion().await?;
1273 self.manifest.backends.set_android(backend);
1274 self.manifest
1275 .save(&self.root)
1276 .await
1277 .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1278 Ok(())
1279 }
1280
1281 pub async fn init_gtk4_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1288 use crate::{backend::Backend, gtk4::backend::Gtk4Backend};
1289
1290 if !cfg!(target_os = "linux") {
1291 return Err(crate::backend::FailToInitBackend::Io(
1292 std::io::Error::other("GTK4 backend is only supported on Linux hosts"),
1293 ));
1294 }
1295
1296 let backend = Gtk4Backend::init(self).await?;
1297 self.manifest.backends.set_gtk4(backend);
1298 self.manifest
1299 .save(&self.root)
1300 .await
1301 .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1302 Ok(())
1303 }
1304
1305 pub async fn init_hydrolysis_backend(
1313 &mut self,
1314 ) -> Result<(), crate::backend::FailToInitBackend> {
1315 use crate::{backend::Backend, hydrolysis::backend::HydrolysisBackend};
1316
1317 let backend = HydrolysisBackend::init(self).await?;
1318 self.manifest.backends.set_hydrolysis(backend);
1319 self.manifest
1320 .save(&self.root)
1321 .await
1322 .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1323
1324 crate::mcp::ensure_mcp_json(&self.root).await?;
1328 Ok(())
1329 }
1330
1331 pub async fn init_esp32_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1339 use crate::{backend::Backend, esp32::backend::Esp32Backend};
1340
1341 let backend = Esp32Backend::init(self).await?;
1342 self.manifest.backends.set_esp32(backend);
1343 self.manifest
1344 .save(&self.root)
1345 .await
1346 .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1347 Ok(())
1348 }
1349
1350 pub async fn set_esp32_chip(
1361 &mut self,
1362 chip: crate::esp32::chip::Esp32Chip,
1363 ) -> eyre::Result<()> {
1364 let current = self.esp32_backend().cloned().unwrap_or_default();
1365 if current.chip() == chip.id() {
1366 return Ok(());
1367 }
1368 self.manifest.backends.set_esp32(current.with_chip(chip));
1369 self.save_manifest().await
1370 }
1371
1372 pub async fn remove_apple_backend(&mut self) -> eyre::Result<()> {
1377 if let Some(backend) = self.apple_backend() {
1378 let path = backend.project_path().to_path_buf();
1379 self.remove_backend_relative_dir(&path).await?;
1380 }
1381 self.manifest.backends.clear_apple();
1382 self.remove_ffi_companion_if_unused().await?;
1383 self.save_manifest().await
1384 }
1385
1386 pub async fn remove_android_backend(&mut self) -> eyre::Result<()> {
1391 if let Some(backend) = self.android_backend() {
1392 let path = backend.project_path().clone();
1393 self.remove_backend_relative_dir(&path).await?;
1394 }
1395 self.manifest.backends.clear_android();
1396 self.remove_ffi_companion_if_unused().await?;
1397 self.save_manifest().await
1398 }
1399
1400 pub async fn remove_gtk4_backend(&mut self) -> eyre::Result<()> {
1405 if let Some(backend) = self.gtk4_backend() {
1406 let path = backend.project_path().clone();
1407 self.remove_backend_relative_dir(&path).await?;
1408 }
1409 self.manifest.backends.clear_gtk4();
1410 self.save_manifest().await
1411 }
1412
1413 pub async fn remove_hydrolysis_backend(&mut self) -> eyre::Result<()> {
1418 if let Some(backend) = self.hydrolysis_backend() {
1419 let path = backend.project_path().clone();
1420 self.remove_backend_relative_dir(&path).await?;
1421 }
1422 self.manifest.backends.clear_hydrolysis();
1423 self.save_manifest().await
1424 }
1425
1426 pub async fn remove_esp32_backend(&mut self) -> eyre::Result<()> {
1431 if let Some(backend) = self.esp32_backend() {
1432 let path = backend.project_path().clone();
1433 self.remove_backend_relative_dir(&path).await?;
1434 }
1435 self.manifest.backends.clear_esp32();
1436 self.save_manifest().await
1437 }
1438
1439 pub async fn open(path: impl AsRef<Path>) -> Result<Self, FailToOpenProject> {
1449 Self::open_with_mode(path, OpenMode::Full).await
1450 }
1451
1452 pub async fn open_for_preview_build(path: impl AsRef<Path>) -> Result<Self, FailToOpenProject> {
1462 Self::open_with_mode(path, OpenMode::PreviewBuild).await
1463 }
1464
1465 async fn refresh_local_patches(project_root: &Path, waterui_path: &Path) -> eyre::Result<()> {
1480 let project_root = project_root.to_path_buf();
1481 let waterui_path = waterui_path.to_path_buf();
1482 unblock(move || {
1483 let checkout = project_root.join(&waterui_path);
1484 let patch_root = templates::patch_manifest_dir(&project_root)?;
1485 if same_directory(&patch_root, &checkout)? {
1486 return Ok(());
1487 }
1488 if !same_directory(&patch_root, &project_root)? {
1489 eyre::bail!(
1494 "This project is a member of the Cargo workspace at {}, so Cargo reads \
1495 [patch] from {} and ignores any copy here. Move the WaterUI checkout's \
1496 [patch] tables — the ones in {} — into that workspace manifest, or take \
1497 the project out of that workspace.",
1498 patch_root.display(),
1499 patch_root.join("Cargo.toml").display(),
1500 checkout.join("Cargo.toml").display(),
1501 );
1502 }
1503 let cargo_path = project_root.join("Cargo.toml");
1504 let text = std::fs::read_to_string(&cargo_path)?;
1505 let current = CargoManifest::from_slice(text.as_bytes())?.patch;
1506 let next = templates::local_framework_patches(&project_root, &waterui_path)?;
1507 if current == next {
1508 return Ok(());
1509 }
1510 let mut document: toml_edit::DocumentMut = text.parse()?;
1511 crate::framework::rewrite_patch_tables(&mut document, ¤t, &next)?;
1512 std::fs::write(&cargo_path, document.to_string())?;
1513 info!(
1514 path = %cargo_path.display(),
1515 "Refreshed the [patch] tables from the local checkout"
1516 );
1517 Ok(())
1518 })
1519 .await
1520 }
1521
1522 #[allow(clippy::too_many_lines)]
1523 async fn open_with_mode(
1524 path: impl AsRef<Path>,
1525 open_mode: OpenMode,
1526 ) -> Result<Self, FailToOpenProject> {
1527 use crate::backend::Backend;
1528
1529 let total_start = std::time::Instant::now();
1530 let path = path.as_ref().to_path_buf();
1531
1532 let manifest_start = std::time::Instant::now();
1533 let manifest = Manifest::open(path.join("Water.toml"))
1534 .await
1535 .map_err(FailToOpenProject::Manifest)?;
1536 if let Some(framework) = &manifest.framework {
1537 framework
1538 .validate_cli()
1539 .map_err(FailToOpenProject::Framework)?;
1540 }
1541 if let Some(local) = &manifest.waterui_path {
1542 validate_local_cli(&path.join(local))
1543 .await
1544 .map_err(FailToOpenProject::Framework)?;
1545 Self::refresh_local_patches(&path, Path::new(local))
1546 .await
1547 .map_err(FailToOpenProject::LocalPatches)?;
1548 }
1549 info!(
1550 path = %path.display(),
1551 open_mode = ?open_mode,
1552 elapsed_ms = manifest_start.elapsed().as_millis(),
1553 "Project::open loaded Water.toml"
1554 );
1555
1556 let cargo_path = path.join("Cargo.toml");
1557
1558 let cargo_manifest_start = std::time::Instant::now();
1559 let cargo_manifest = unblock(move || CargoManifest::from_path(cargo_path))
1560 .await
1561 .map_err(FailToOpenProject::CargoManifest)?;
1562 info!(
1563 path = %path.display(),
1564 open_mode = ?open_mode,
1565 elapsed_ms = cargo_manifest_start.elapsed().as_millis(),
1566 "Project::open loaded Cargo.toml"
1567 );
1568 let crate_name = cargo_manifest
1569 .package
1570 .map(|p| p.name)
1571 .ok_or(FailToOpenProject::MissingCrateName)
1572 .and_then(|value| {
1573 CrateName::try_from(value).map_err(FailToOpenProject::InvalidCrateName)
1574 })?;
1575
1576 let is_playground = manifest.package.package_type == PackageType::Playground;
1577
1578 if !is_playground && !manifest.permissions.is_empty() {
1580 return Err(FailToOpenProject::PermissionsNotAllowedInNonPlayground);
1581 }
1582
1583 if is_playground && manifest.backends.configures_backend_projects() {
1589 return Err(FailToOpenProject::BackendsNotAllowedInPlayground);
1590 }
1591
1592 let cargo_layout = spawn_cargo_layout_resolution(
1593 &path,
1594 manifest.framework.clone(),
1595 manifest.waterui_path.is_some(),
1596 );
1597 cargo_layout
1598 .clone()
1599 .await
1600 .map_err(|error| FailToOpenProject::Framework(eyre::eyre!(error)))?;
1601
1602 let managed_backends_root = if is_playground {
1603 let build_cache_start = std::time::Instant::now();
1604 let root = crate::water_dir::ensure_project_build_cache(&path)
1605 .await
1606 .map_err(FailToOpenProject::BuildCache)?;
1607 info!(
1608 path = %path.display(),
1609 open_mode = ?open_mode,
1610 elapsed_ms = build_cache_start.elapsed().as_millis(),
1611 "Project::open ensured project build cache"
1612 );
1613 root
1614 } else {
1615 path.join(manifest.backends.path())
1616 };
1617
1618 let mut project = Self {
1619 root: path,
1620 manifest,
1621 crate_name,
1622 cargo_layout,
1623 linked_packages: Arc::new(async_lock::OnceCell::new()),
1624 enabled_features: Arc::new(async_lock::OnceCell::new()),
1625 managed_backends_root,
1626 };
1627
1628 let skip_backend_init = std::env::var("WATERUI_SKIP_RUST_BUILD")
1637 .is_ok_and(|value| value == "1")
1638 || std::env::var("ACTION").is_ok() || std::env::var("XCODE_PRODUCT_BUILD_VERSION").is_ok();
1640
1641 if is_playground && !skip_backend_init && open_mode == OpenMode::Full {
1642 let apple_backend_start = std::time::Instant::now();
1643 let apple_backend = AppleBackend::init(&project)
1644 .await
1645 .map_err(FailToOpenProject::BackendInit)?;
1646 info!(
1647 path = %project.root.display(),
1648 elapsed_ms = apple_backend_start.elapsed().as_millis(),
1649 "Project::open initialized Apple backend"
1650 );
1651 project.manifest.backends.set_apple(apple_backend);
1652
1653 let android_backend_start = std::time::Instant::now();
1654 let android_backend = AndroidBackend::init(&project)
1655 .await
1656 .map_err(FailToOpenProject::BackendInit)?;
1657 info!(
1658 path = %project.root.display(),
1659 elapsed_ms = android_backend_start.elapsed().as_millis(),
1660 "Project::open initialized Android backend"
1661 );
1662 project.manifest.backends.set_android(android_backend);
1663
1664 let ffi_companion_start = std::time::Instant::now();
1665 project
1666 .scaffold_ffi_companion()
1667 .await
1668 .map_err(FailToOpenProject::BackendInit)?;
1669 info!(
1670 path = %project.root.display(),
1671 elapsed_ms = ffi_companion_start.elapsed().as_millis(),
1672 "Project::open scaffolded native ffi companion"
1673 );
1674 }
1675
1676 if !is_playground
1677 && !skip_backend_init
1678 && open_mode == OpenMode::Full
1679 && (project.apple_backend().is_some() || project.android_backend().is_some())
1680 {
1681 let ffi_companion_start = std::time::Instant::now();
1682 project
1683 .scaffold_ffi_companion()
1684 .await
1685 .map_err(FailToOpenProject::BackendInit)?;
1686 info!(
1687 path = %project.root.display(),
1688 elapsed_ms = ffi_companion_start.elapsed().as_millis(),
1689 "Project::open refreshed native ffi companion"
1690 );
1691 }
1692
1693 info!(
1694 path = %project.root.display(),
1695 open_mode = ?open_mode,
1696 elapsed_ms = total_start.elapsed().as_millis(),
1697 "Project::open completed"
1698 );
1699
1700 Ok(project)
1701 }
1702}
1703
1704impl Project {
1705 async fn save_manifest(&self) -> eyre::Result<()> {
1706 self.manifest.save(&self.root).await.map_err(Into::into)
1707 }
1708
1709 async fn remove_backend_relative_dir(&self, relative_path: &Path) -> eyre::Result<()> {
1710 let backend_path = self.managed_backends_root.join(relative_path);
1711 if backend_path.exists() {
1712 smol::fs::remove_dir_all(&backend_path).await?;
1713 }
1714 Ok(())
1715 }
1716}
1717
1718async fn apply_channel_selection(
1719 root: &Path,
1720 framework: ResolvedFramework,
1721 updates: Vec<(PathBuf, Option<Vec<u8>>)>,
1722) -> eyre::Result<()> {
1723 let mut previous = BTreeMap::new();
1724 for file in updates
1725 .iter()
1726 .map(|(file, _)| file.clone())
1727 .chain([root.join("Cargo.lock")])
1728 {
1729 let contents = match smol::fs::read(&file).await {
1730 Ok(contents) => Some(contents),
1731 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
1732 Err(error) => return Err(error.into()),
1733 };
1734 previous.insert(file, contents);
1735 }
1736 let result = async {
1737 for (file, contents) in &updates {
1738 write_channel_file(file, contents.as_deref()).await?;
1739 }
1740 resolve_cargo_layout(root, Some(framework), CargoResolution::Update).await?;
1741 Ok(())
1742 }
1743 .await;
1744 if let Err(error) = result {
1745 for (file, contents) in previous {
1746 write_channel_file(&file, contents.as_deref())
1747 .await
1748 .map_err(|restore| {
1749 eyre::eyre!("{error}; could not restore {}: {restore}", file.display())
1750 })?;
1751 }
1752 return Err(error);
1753 }
1754 Ok(())
1755}
1756
1757async fn write_channel_file(path: &Path, contents: Option<&[u8]>) -> std::io::Result<()> {
1758 match contents {
1759 Some(contents) => smol::fs::write(path, contents).await,
1760 None => match smol::fs::remove_file(path).await {
1761 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1762 result => result,
1763 },
1764 }
1765}
1766
1767async fn resolve_cargo_layout(
1768 current_dir: &Path,
1769 framework: Option<ResolvedFramework>,
1770 mode: CargoResolution,
1771) -> eyre::Result<CargoLayout> {
1772 let root = current_dir.to_path_buf();
1773 let metadata = unblock(move || {
1774 let mut command = cargo_metadata::MetadataCommand::new();
1775 command.current_dir(root);
1776 match mode {
1777 CargoResolution::Local => {
1778 command.no_deps();
1779 }
1780 CargoResolution::Locked => {
1781 command.other_options(vec!["--locked".to_string()]);
1782 }
1783 CargoResolution::Update => {}
1784 }
1785 command.exec()
1786 })
1787 .await?;
1788 validate_resolved_cli(&metadata)?;
1789 if let Some(framework) = framework
1790 && framework.channel() != Some(FrameworkChannel::Stable)
1791 {
1792 let lockfile = smol::fs::read(current_dir.join("Water.lock")).await?;
1793 framework.validate_dependencies(&metadata, &lockfile)?;
1794 }
1795
1796 Ok(CargoLayout {
1797 target_dir: metadata.target_directory.into_std_path_buf(),
1798 workspace_root: metadata.workspace_root.into_std_path_buf(),
1799 })
1800}
1801
1802async fn cargo_tree(project_root: &Path, edges: &str) -> eyre::Result<String> {
1805 let application_manifest = dunce::canonicalize(project_root.join("Cargo.toml"))?;
1813 let metadata_manifest = application_manifest.clone();
1814 let metadata = unblock(move || {
1815 cargo_metadata::MetadataCommand::new()
1816 .no_deps()
1817 .manifest_path(metadata_manifest)
1818 .exec()
1819 })
1820 .await?;
1821 let root = metadata
1822 .packages
1823 .iter()
1824 .find(|package| package.manifest_path.as_std_path() == application_manifest)
1825 .ok_or_else(|| {
1826 eyre::eyre!(
1827 "Cargo metadata omitted the application package at {}",
1828 application_manifest.display()
1829 )
1830 })?;
1831 let package_spec = root.id.to_string();
1832 let output = Command::new("cargo")
1833 .arg("tree")
1834 .arg("--manifest-path")
1835 .arg(&application_manifest)
1836 .arg("--package")
1837 .arg(package_spec)
1838 .arg("--edges")
1839 .arg(edges)
1840 .arg("--prefix")
1841 .arg("none")
1842 .arg("--format")
1843 .arg("{p}")
1844 .current_dir(project_root)
1845 .output()
1846 .await?;
1847 if !output.status.success() {
1848 return Err(eyre::eyre!(
1849 "failed to resolve runtime dependency graph for {}: {}",
1850 application_manifest.display(),
1851 String::from_utf8_lossy(&output.stderr).trim()
1852 ));
1853 }
1854
1855 String::from_utf8(output.stdout)
1856 .map_err(|error| eyre::eyre!("Cargo runtime dependency graph is not UTF-8: {error}"))
1857}
1858
1859async fn resolve_linked_runtime_packages(
1860 project_root: PathBuf,
1861) -> eyre::Result<BTreeMap<String, String>> {
1862 let tree = cargo_tree(&project_root, "normal").await?;
1863 let mut linked = BTreeMap::new();
1864 for package in tree.lines() {
1865 let name = package
1866 .split_ascii_whitespace()
1867 .next()
1868 .ok_or_else(|| eyre::eyre!("Cargo emitted an empty runtime dependency entry"))?;
1869 linked.insert(name.to_string(), package.to_string());
1870 }
1871
1872 Ok(linked)
1873}
1874
1875async fn resolve_enabled_features(project_root: PathBuf) -> eyre::Result<BTreeSet<String>> {
1880 let tree = cargo_tree(&project_root, "features").await?;
1881 let mut features = BTreeSet::new();
1882 for node in tree.lines() {
1883 if let Some(feature) = node
1884 .split_once(" feature \"")
1885 .and_then(|(_, rest)| rest.strip_suffix('"'))
1886 {
1887 features.insert(feature.to_string());
1888 }
1889 }
1890 Ok(features)
1891}
1892
1893use std::{
1894 collections::{BTreeMap, BTreeSet},
1895 path::{Path, PathBuf},
1896 sync::Arc,
1897};
1898
1899use serde::{Deserialize, Serialize};
1900use smol::{fs::read_to_string, process::Command, unblock};
1901use waterui_assets_planner::{LaunchConfig, ThemeConfig};
1902
1903use crate::{
1904 android::{backend::AndroidBackend, device::AndroidAbiProvider, platform::AndroidPlatform},
1905 apple::backend::AppleBackend,
1906 backend::{Backend, Backends},
1907 build::{BuildOptions, BuildProfile},
1908 device::{Artifact, Device, FailToRun, RunOptions, Running},
1909 platform::{PackageOptions, TargetBackend, TargetPlatform},
1910 project_types::{BundleIdentifier, CrateName, PermissionKey},
1911 templates::{self, TemplateContext},
1912 utils::command,
1913 web,
1914};
1915
1916#[derive(Debug, Serialize, Deserialize, Clone)]
1918pub struct Manifest {
1919 pub package: Package,
1921 #[serde(default, skip_serializing_if = "Backends::is_empty")]
1923 pub backends: Backends,
1924 #[serde(skip_serializing_if = "Option::is_none")]
1928 pub waterui_path: Option<String>,
1929 #[serde(default, skip_serializing_if = "Option::is_none")]
1931 pub framework: Option<ResolvedFramework>,
1932 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1934 pub permissions: BTreeMap<PermissionKey, PermissionEntry>,
1935 #[serde(default, skip_serializing_if = "Option::is_none")]
1937 pub app: Option<AppConfig>,
1938 #[serde(default, skip_serializing_if = "Option::is_none")]
1940 pub theme: Option<ThemeConfig>,
1941 #[serde(default, skip_serializing_if = "Option::is_none")]
1943 pub launch: Option<LaunchConfig>,
1944 #[serde(default, skip_serializing_if = "Option::is_none")]
1946 pub web: Option<web::WebConfig>,
1947}
1948
1949#[derive(Debug, Serialize, Deserialize, Clone)]
1951pub struct PermissionEntry {
1952 enable: bool,
1953 description: String,
1955}
1956
1957impl PermissionEntry {
1958 #[must_use]
1960 pub const fn is_enabled(&self) -> bool {
1961 self.enable
1962 }
1963
1964 #[must_use]
1966 pub fn description(&self) -> &str {
1967 &self.description
1968 }
1969}
1970
1971#[derive(Debug, thiserror::Error)]
1973pub enum FailToOpenManifest {
1974 #[error("Failed to read manifest file: {0}")]
1976 ReadError(std::io::Error),
1977 #[error("Invalid manifest file: {0}")]
1979 InvalidManifest(toml::de::Error),
1980
1981 #[error("Manifest file not found at the specified path")]
1983 NotFound,
1984}
1985
1986#[derive(Debug, thiserror::Error)]
1988pub enum FailToSaveManifest {
1989 #[error("Failed to serialize manifest: {0}")]
1991 Serialize(toml::ser::Error),
1992 #[error("Failed to write manifest file: {0}")]
1994 Write(std::io::Error),
1995}
1996impl Manifest {
1997 pub async fn open(path: impl AsRef<Path>) -> Result<Self, FailToOpenManifest> {
2004 let path = path.as_ref();
2005 let result = read_to_string(path).await;
2006
2007 match result {
2008 Ok(c) => toml::from_str(&c).map_err(FailToOpenManifest::InvalidManifest),
2009 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(FailToOpenManifest::NotFound),
2010 Err(e) => Err(FailToOpenManifest::ReadError(e)),
2011 }
2012 }
2013
2014 pub async fn save(&self, dir: impl AsRef<Path>) -> Result<(), FailToSaveManifest> {
2020 let path = dir.as_ref().join("Water.toml");
2021 let content = toml::to_string_pretty(self).map_err(FailToSaveManifest::Serialize)?;
2022 smol::fs::write(&path, content)
2023 .await
2024 .map_err(FailToSaveManifest::Write)
2025 }
2026
2027 #[must_use]
2029 pub fn new(package: Package) -> Self {
2030 Self {
2031 package,
2032 backends: Backends::default(),
2033 waterui_path: None,
2034 framework: None,
2035 permissions: BTreeMap::default(),
2036 app: None,
2037 theme: None,
2038 launch: None,
2039 web: None,
2040 }
2041 }
2042}
2043
2044#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2046pub enum ResolvedWebViewBackend {
2047 System,
2049 Wpe,
2051 Cef,
2053}
2054
2055#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2057pub struct BrowserRuntimePlan {
2058 pub webview: Option<ResolvedWebViewBackend>,
2060 pub chromium: bool,
2062}
2063
2064impl BrowserRuntimePlan {
2065 #[must_use]
2068 pub const fn requires_cef(self) -> bool {
2069 self.chromium || matches!(self.webview, Some(ResolvedWebViewBackend::Cef))
2070 }
2071}
2072
2073impl ResolvedWebViewBackend {
2074 #[must_use]
2076 pub const fn supports(self, platform: TargetPlatform, backend: TargetBackend) -> bool {
2077 match self {
2078 Self::System => matches!(
2079 (platform, backend),
2080 (
2081 TargetPlatform::MacOS,
2082 TargetBackend::Apple | TargetBackend::Hydrolysis
2083 ) | (
2084 TargetPlatform::IOS
2085 | TargetPlatform::IOSSimulator
2086 | TargetPlatform::VisionOS
2087 | TargetPlatform::VisionOSSimulator,
2088 TargetBackend::Apple
2089 ) | (TargetPlatform::Android, TargetBackend::Android)
2090 | (TargetPlatform::Linux, TargetBackend::Gtk4)
2091 | (TargetPlatform::Web, TargetBackend::Hydrolysis)
2092 ),
2093 Self::Wpe => {
2094 matches!(platform, TargetPlatform::Linux)
2095 && matches!(backend, TargetBackend::Gtk4 | TargetBackend::Hydrolysis)
2096 }
2097 Self::Cef => cef_is_supported(platform, backend),
2098 }
2099 }
2100
2101 pub const fn validate(
2108 self,
2109 platform: TargetPlatform,
2110 backend: TargetBackend,
2111 ) -> Result<Self, UnsupportedWebViewBackend> {
2112 if self.supports(platform, backend) {
2113 Ok(self)
2114 } else {
2115 Err(UnsupportedWebViewBackend {
2116 resolved: self,
2117 platform,
2118 backend,
2119 })
2120 }
2121 }
2122
2123 #[must_use]
2125 pub const fn as_str(self) -> &'static str {
2126 match self {
2127 Self::System => "system",
2128 Self::Wpe => "wpe",
2129 Self::Cef => "cef",
2130 }
2131 }
2132}
2133
2134const fn cef_is_supported(platform: TargetPlatform, backend: TargetBackend) -> bool {
2135 !matches!(backend, TargetBackend::Dew)
2136 && matches!(
2137 platform,
2138 TargetPlatform::MacOS | TargetPlatform::Linux | TargetPlatform::Windows
2139 )
2140}
2141
2142#[derive(Debug, thiserror::Error)]
2144#[error(
2145 "this application's WebView engine resolves to {resolved:?}, which is unsupported for \
2146 platform {platform:?} with backend {backend:?}. The engine follows the application's \
2147 dependencies: link waterui-browser-cef or waterui-browser-wpe to select one, or \
2148 neither to use the engine this platform bridges."
2149)]
2150pub struct UnsupportedWebViewBackend {
2151 resolved: ResolvedWebViewBackend,
2152 platform: TargetPlatform,
2153 backend: TargetBackend,
2154}
2155
2156#[derive(Debug, Serialize, Deserialize, Clone, Default)]
2158pub struct AppConfig {
2159 #[serde(default, skip_serializing_if = "Option::is_none")]
2161 pub crates: Option<AppCrates>,
2162}
2163
2164#[derive(Debug, Serialize, Deserialize, Clone, Default)]
2166pub struct AppCrates {
2167 #[serde(default, skip_serializing_if = "Option::is_none")]
2169 pub ffi: Option<CrateName>,
2170 #[serde(default, skip_serializing_if = "Option::is_none")]
2172 pub gtk: Option<CrateName>,
2173 #[serde(default, skip_serializing_if = "Option::is_none")]
2175 pub hydrolysis: Option<CrateName>,
2176}
2177
2178#[derive(Debug, Serialize, Deserialize, Clone)]
2180pub struct Package {
2181 #[serde(rename = "type")]
2183 pub package_type: PackageType,
2184 pub name: String,
2186 pub bundle_identifier: BundleIdentifier,
2188 #[serde(
2190 default = "default_assets_path",
2191 skip_serializing_if = "is_default_assets_path"
2192 )]
2193 pub assets_path: String,
2194 #[serde(default, skip_serializing_if = "is_false")]
2196 pub accessory: bool,
2197}
2198
2199pub async fn read_project_crate_name(project_path: &Path) -> eyre::Result<String> {
2209 let cargo_toml = project_path.join("Cargo.toml");
2210 let cargo_content = smol::fs::read_to_string(&cargo_toml).await?;
2211 let cargo: toml::Table = cargo_content.parse()?;
2212 cargo
2213 .get("package")
2214 .and_then(|p| p.get("name"))
2215 .and_then(|n| n.as_str())
2216 .map(ToString::to_string)
2217 .ok_or_else(|| eyre::eyre!("Could not find package name in Cargo.toml"))
2218}
2219
2220fn same_directory(left: &Path, right: &Path) -> std::io::Result<bool> {
2227 Ok(std::fs::canonicalize(left)? == std::fs::canonicalize(right)?)
2228}
2229
2230fn default_assets_path() -> String {
2231 "assets".to_string()
2232}
2233
2234fn is_default_assets_path(path: &str) -> bool {
2235 path == "assets"
2236}
2237
2238#[allow(clippy::trivially_copy_pass_by_ref)]
2239const fn is_false(value: &bool) -> bool {
2240 !*value
2241}
2242
2243#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq)]
2245#[serde(rename_all = "lowercase")]
2246pub enum PackageType {
2247 #[default]
2249 App,
2250 Playground,
2253}
2254
2255#[cfg(test)]
2256mod channel_tests {
2257 use super::*;
2258
2259 #[test]
2260 fn local_framework_requirement_is_checked_before_project_io() {
2261 smol::block_on(async {
2262 let directory = tempfile::tempdir().unwrap();
2263 let framework_root = directory.path().join("framework");
2264 let project_root = directory.path().join("consumer");
2265 smol::fs::create_dir(&framework_root).await.unwrap();
2266 let mut minimum: cargo_toml::SemVer = env!("CARGO_PKG_VERSION").parse().unwrap();
2267 minimum.major += 1;
2268 let mut metadata = toml::toml! {
2269 [package.metadata.waterui]
2270 minimum-cli-version = "0.1.4"
2271 android-min-api-level = 26
2272 };
2273 metadata["package"]["metadata"]["waterui"]["minimum-cli-version"] =
2274 toml::Value::String(minimum.to_string());
2275 smol::fs::write(
2276 framework_root.join("Cargo.toml"),
2277 toml::to_string(&metadata).unwrap(),
2278 )
2279 .await
2280 .unwrap();
2281 let bundle_identifier =
2282 BundleIdentifier::try_from("dev.waterui.compatibility").unwrap();
2283 let options = CreateOptions {
2284 name: "Compatibility".into(),
2285 bundle_identifier: bundle_identifier.clone(),
2286 package_type: PackageType::Playground,
2287 waterui_path: Some(framework_root),
2288 channel: None,
2289 framework_manifest: None,
2290 framework: None,
2291 author: String::new(),
2292 web: None,
2293 };
2294 let error = Project::create(&project_root, options)
2295 .await
2296 .unwrap_err()
2297 .to_string();
2298 assert!(error.contains(&format!("requires waterui-cli >= {minimum}")));
2299 assert!(error.contains(&format!(
2300 "cargo install waterui-cli --git {} --locked",
2301 env!("CARGO_PKG_REPOSITORY")
2302 )));
2303 assert!(!project_root.exists());
2304
2305 smol::fs::create_dir(&project_root).await.unwrap();
2306 let mut manifest = Manifest::new(Package {
2307 name: "Compatibility".into(),
2308 bundle_identifier,
2309 package_type: PackageType::Playground,
2310 assets_path: default_assets_path(),
2311 accessory: false,
2312 });
2313 manifest.waterui_path = Some("../framework".into());
2314 manifest.save(&project_root).await.unwrap();
2315 let error = Project::open_for_preview_build(&project_root)
2316 .await
2317 .unwrap_err()
2318 .to_string();
2319 assert!(error.contains(&format!("requires waterui-cli >= {minimum}")));
2320 assert!(!project_root.join("Cargo.lock").exists());
2321 });
2322 }
2323
2324 #[test]
2325 fn failed_channel_selection_preserves_project_files() {
2326 smol::block_on(async {
2327 let directory = tempfile::tempdir().unwrap();
2328 let root = directory.path();
2329 let originals = [
2330 ("Cargo.toml", b"original manifest".as_slice()),
2331 ("Cargo.lock", b"original dependency lock".as_slice()),
2332 ("Water.toml", b"original project configuration".as_slice()),
2333 ];
2334 for (name, contents) in originals {
2335 smol::fs::write(root.join(name), contents).await.unwrap();
2336 }
2337 let updates = ["Cargo.toml", "Cargo.lock", "Water.toml", "Water.lock"]
2338 .into_iter()
2339 .map(|name| (root.join(name), Some(b"invalid selected manifest".to_vec())))
2340 .collect();
2341 assert!(
2342 apply_channel_selection(
2343 root,
2344 crate::framework::test_fixtures::stable_framework(),
2345 updates
2346 )
2347 .await
2348 .is_err()
2349 );
2350 for (name, contents) in originals {
2351 assert_eq!(smol::fs::read(root.join(name)).await.unwrap(), contents);
2352 }
2353 assert!(!root.join("Water.lock").exists());
2354 });
2355 }
2356}
2357
2358#[cfg(test)]
2359mod webview_backend_tests {
2360 use super::{
2361 ResolvedWebViewBackend, TargetBackend, TargetPlatform, resolve_enabled_features,
2362 resolve_linked_runtime_packages,
2363 };
2364
2365 #[test]
2370 fn the_platform_bridge_is_the_selection_without_an_engine_crate() {
2371 assert_eq!(
2372 ResolvedWebViewBackend::System
2373 .validate(TargetPlatform::MacOS, TargetBackend::Hydrolysis)
2374 .expect("macOS Hydrolysis bridges WKWebView"),
2375 ResolvedWebViewBackend::System
2376 );
2377 assert_eq!(
2378 ResolvedWebViewBackend::System
2379 .validate(TargetPlatform::Linux, TargetBackend::Gtk4)
2380 .expect("GTK bridges WebKitGTK"),
2381 ResolvedWebViewBackend::System
2382 );
2383 assert!(
2384 ResolvedWebViewBackend::System
2385 .validate(TargetPlatform::Linux, TargetBackend::Hydrolysis)
2386 .is_err()
2387 );
2388 assert!(
2389 ResolvedWebViewBackend::System
2390 .validate(TargetPlatform::Windows, TargetBackend::Hydrolysis)
2391 .is_err()
2392 );
2393 }
2394
2395 #[test]
2396 fn unsupported_engine_combinations_fail_before_build() {
2397 assert!(
2398 ResolvedWebViewBackend::Wpe
2399 .validate(TargetPlatform::MacOS, TargetBackend::Hydrolysis)
2400 .is_err()
2401 );
2402 assert!(
2403 ResolvedWebViewBackend::Cef
2404 .validate(TargetPlatform::Android, TargetBackend::Android)
2405 .is_err()
2406 );
2407 assert_eq!(
2408 ResolvedWebViewBackend::Cef
2409 .validate(TargetPlatform::MacOS, TargetBackend::Apple)
2410 .expect("CEF must compose with the native Apple renderer on macOS"),
2411 ResolvedWebViewBackend::Cef
2412 );
2413 }
2414
2415 #[test]
2416 fn cef_is_available_to_every_non_dew_backend_on_desktop_platforms() {
2417 for backend in [
2418 TargetBackend::Apple,
2419 TargetBackend::Android,
2420 TargetBackend::Gtk4,
2421 TargetBackend::Hydrolysis,
2422 ] {
2423 for platform in [
2424 TargetPlatform::MacOS,
2425 TargetPlatform::Linux,
2426 TargetPlatform::Windows,
2427 ] {
2428 assert_eq!(
2429 ResolvedWebViewBackend::Cef
2430 .validate(platform, backend)
2431 .expect("CEF availability must not depend on the WaterUI backend"),
2432 ResolvedWebViewBackend::Cef
2433 );
2434 }
2435 }
2436 }
2437
2438 #[test]
2439 fn cef_rejects_dew_and_platforms_without_cef_distributions() {
2440 for platform in [
2441 TargetPlatform::MacOS,
2442 TargetPlatform::Linux,
2443 TargetPlatform::Windows,
2444 ] {
2445 assert!(
2446 ResolvedWebViewBackend::Cef
2447 .validate(platform, TargetBackend::Dew)
2448 .is_err()
2449 );
2450 }
2451 for (platform, backend) in [
2452 (TargetPlatform::Android, TargetBackend::Android),
2453 (TargetPlatform::IOS, TargetBackend::Apple),
2454 (TargetPlatform::Web, TargetBackend::Hydrolysis),
2455 ] {
2456 assert!(
2457 ResolvedWebViewBackend::Cef
2458 .validate(platform, backend)
2459 .is_err()
2460 );
2461 }
2462 }
2463
2464 #[test]
2470 #[ignore = "clones the pinned framework revision"]
2471 fn runtime_graph_is_scoped_to_the_selected_application() {
2472 let checkout = crate::pinned_framework::checkout();
2473 let repository = checkout.path();
2474 let chromium = smol::block_on(resolve_linked_runtime_packages(
2475 repository.join("examples/chromium"),
2476 ))
2477 .expect("Chromium example runtime graph must resolve");
2478 assert!(
2479 chromium.contains_key("waterui-chromium"),
2480 "Chromium example graph: {chromium:#?}"
2481 );
2482 assert!(
2485 chromium.contains_key("waterui-browser-cef"),
2486 "Chromium example graph: {chromium:#?}"
2487 );
2488 assert!(
2489 !chromium.contains_key("waterui-browser-wpe"),
2490 "Chromium example graph: {chromium:#?}"
2491 );
2492 assert!(
2499 chromium.contains_key("waterui-webview"),
2500 "waterui-chromium shares the webview asset-server types: {chromium:#?}"
2501 );
2502 let chromium_features = smol::block_on(resolve_enabled_features(
2503 repository.join("examples/chromium"),
2504 ))
2505 .expect("Chromium example feature graph must resolve");
2506 assert!(
2507 !chromium_features.contains("webview"),
2508 "a Chromium-only application must not enable the standard WebView \
2509 component: {chromium_features:#?}"
2510 );
2511
2512 let webview = smol::block_on(resolve_linked_runtime_packages(
2513 repository.join("examples/webview"),
2514 ))
2515 .expect("WebView example runtime graph must resolve");
2516 assert!(
2517 webview.contains_key("waterui-webview"),
2518 "WebView example graph: {webview:#?}"
2519 );
2520 let webview_features = smol::block_on(resolve_enabled_features(
2521 repository.join("examples/webview"),
2522 ))
2523 .expect("WebView example feature graph must resolve");
2524 assert!(
2525 webview_features.contains("webview"),
2526 "the WebView example enables the facade `webview` feature: {webview_features:#?}"
2527 );
2528 assert!(
2529 !webview.contains_key("waterui-browser-cef"),
2530 "WebView example graph: {webview:#?}"
2531 );
2532 assert!(
2533 !webview.contains_key("waterui-chromium"),
2534 "WebView example graph: {webview:#?}"
2535 );
2536
2537 let cef_webview = smol::block_on(resolve_linked_runtime_packages(
2538 repository.join("examples/webview-cef"),
2539 ))
2540 .expect("CEF WebView example runtime graph must resolve");
2541 assert!(
2542 cef_webview.contains_key("waterui-browser-cef"),
2543 "CEF WebView example graph: {cef_webview:#?}"
2544 );
2545 assert!(
2546 !cef_webview.contains_key("waterui-browser-wpe"),
2547 "CEF WebView example graph: {cef_webview:#?}"
2548 );
2549 }
2550
2551 #[test]
2559 #[ignore = "clones the pinned framework revision"]
2560 fn the_map_capability_is_read_from_the_application_graph() {
2561 let checkout = crate::pinned_framework::checkout();
2562 let repository = checkout.path();
2563
2564 let map = smol::block_on(resolve_linked_runtime_packages(
2565 repository.join("examples/map"),
2566 ))
2567 .expect("map example runtime graph must resolve");
2568 assert!(
2569 map.contains_key("waterui-map"),
2570 "map example graph: {map:#?}"
2571 );
2572
2573 let webview = smol::block_on(resolve_linked_runtime_packages(
2574 repository.join("examples/webview"),
2575 ))
2576 .expect("WebView example runtime graph must resolve");
2577 assert!(
2578 !webview.contains_key("waterui-map"),
2579 "an application that shows no map must not carry the map stack: {webview:#?}"
2580 );
2581 }
2582}
2583
2584#[cfg(test)]
2585mod scaffold_tests {
2586 use super::{BundleIdentifier, CreateOptions, PackageType, Project};
2587
2588 #[test]
2593 fn create_scaffolds_the_assets_directory_declared_by_the_manifest() {
2594 let dir = tempfile::tempdir().expect("temp dir");
2595 let root = dir.path().join("water-example");
2596
2597 let project = smol::block_on(Project::create(
2598 &root,
2599 CreateOptions {
2600 name: "Water Example".to_string(),
2601 bundle_identifier: BundleIdentifier::try_from("dev.waterui.waterexample")
2602 .expect("bundle identifier"),
2603 package_type: PackageType::Playground,
2604 waterui_path: None,
2605 channel: None,
2606 framework_manifest: None,
2607 framework: Some(crate::framework::test_fixtures::stable_framework()),
2610 author: "Lexo Liu".to_string(),
2611 web: None,
2612 },
2613 ))
2614 .expect("project creation must succeed");
2615
2616 let assets = project.assets_dir();
2617 assert!(
2618 assets.is_dir(),
2619 "the assets root {} must exist after `water create`",
2620 assets.display()
2621 );
2622 assert_eq!(
2623 assets,
2624 root.join(project.assets_path()),
2625 "the scaffolded directory must be the one the manifest declares"
2626 );
2627 assert!(
2628 assets.join("README.md").is_file(),
2629 "a tracked file keeps the assets directory present in git"
2630 );
2631 }
2632}
2633
2634#[cfg(test)]
2635mod local_patch_tests {
2636 use std::path::Path;
2637
2638 use super::Project;
2639
2640 #[test]
2644 fn a_local_checkout_project_follows_the_checkouts_patch_tables() {
2645 let directory = tempfile::tempdir().expect("temp dir");
2646 let checkout = directory.path().join("waterui");
2647 std::fs::create_dir_all(&checkout).expect("checkout dir");
2648 std::fs::write(
2649 checkout.join("Cargo.toml"),
2650 include_str!("../../tests/fixtures/local_checkout_patches.toml"),
2651 )
2652 .expect("checkout manifest");
2653 let app = directory.path().join("app");
2654 std::fs::create_dir_all(&app).expect("project dir");
2655 let cargo_path = app.join("Cargo.toml");
2656 std::fs::write(
2657 &cargo_path,
2658 "[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",
2659 )
2660 .expect("project manifest");
2661
2662 smol::block_on(Project::refresh_local_patches(
2663 &app,
2664 Path::new("../waterui"),
2665 ))
2666 .expect("tables refresh");
2667 let refreshed = std::fs::read_to_string(&cargo_path).expect("refreshed manifest");
2668 let manifest = cargo_toml::Manifest::from_str(&refreshed).expect("manifest parses");
2669 let crates_io = &manifest.patch["crates-io"];
2670 let cargo_toml::Dependency::Detailed(core) = &crates_io["waterui-core"] else {
2671 panic!("the core patch is a path dependency");
2672 };
2673 assert_eq!(core.path.as_deref(), Some("../waterui/core"));
2674 assert!(!crates_io.contains_key("stale"));
2675 assert!(crates_io.contains_key("vello"));
2676 assert!(refreshed.starts_with("[package]"));
2677
2678 smol::block_on(Project::refresh_local_patches(
2679 &app,
2680 Path::new("../waterui"),
2681 ))
2682 .expect("second refresh");
2683 assert_eq!(
2684 std::fs::read_to_string(&cargo_path).expect("manifest after the second refresh"),
2685 refreshed
2686 );
2687 }
2688
2689 #[test]
2694 fn a_member_of_the_checkouts_workspace_keeps_its_manifest() {
2695 let directory = tempfile::tempdir().expect("temp dir");
2696 let checkout = directory.path().join("waterui");
2697 let app = checkout.join("examples/app");
2698 std::fs::create_dir_all(&app).expect("project dir");
2699 std::fs::write(
2700 checkout.join("Cargo.toml"),
2701 include_str!("../../tests/fixtures/local_checkout_patches.toml"),
2702 )
2703 .expect("checkout manifest");
2704 let manifest = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nwaterui = { path = \"../..\" }\n";
2705 let cargo_path = app.join("Cargo.toml");
2706 std::fs::write(&cargo_path, manifest).expect("project manifest");
2707
2708 smol::block_on(Project::refresh_local_patches(&app, Path::new("../..")))
2709 .expect("tables refresh");
2710
2711 assert_eq!(
2712 std::fs::read_to_string(&cargo_path).expect("manifest after the refresh"),
2713 manifest
2714 );
2715 }
2716
2717 #[test]
2721 fn a_member_of_a_foreign_workspace_is_told_where_the_tables_belong() {
2722 let directory = tempfile::tempdir().expect("temp dir");
2723 let checkout = directory.path().join("waterui");
2724 std::fs::create_dir_all(&checkout).expect("checkout dir");
2725 std::fs::write(
2726 checkout.join("Cargo.toml"),
2727 include_str!("../../tests/fixtures/local_checkout_patches.toml"),
2728 )
2729 .expect("checkout manifest");
2730 let workspace = directory.path().join("their-workspace");
2731 let app = workspace.join("app");
2732 std::fs::create_dir_all(&app).expect("project dir");
2733 std::fs::write(
2734 workspace.join("Cargo.toml"),
2735 "[workspace]\nmembers = [\"app\"]\n",
2736 )
2737 .expect("workspace manifest");
2738 let manifest = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nwaterui = { path = \"../../waterui\" }\n";
2739 let cargo_path = app.join("Cargo.toml");
2740 std::fs::write(&cargo_path, manifest).expect("project manifest");
2741
2742 let error = smol::block_on(Project::refresh_local_patches(
2743 &app,
2744 Path::new("../../waterui"),
2745 ))
2746 .expect_err("a copy here would be ignored");
2747
2748 let message = error.to_string();
2749 assert!(message.contains("their-workspace"), "{message}");
2750 assert_eq!(
2751 std::fs::read_to_string(&cargo_path).expect("manifest after the refusal"),
2752 manifest
2753 );
2754 }
2755}