Skip to main content

waterui_cli/project_model/
project.rs

1//! Project management and build utilities for `WaterUI` CLI.
2
3use 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/// What `cargo metadata` reports about the tree a project builds in.
20///
21/// Resolved once per [`Project`] and shared by everything that needs it, so
22/// one `cargo metadata` run serves the target directory and the lockfile.
23#[derive(Debug, Clone)]
24struct CargoLayout {
25    target_dir: PathBuf,
26    /// Root of the Cargo workspace the project belongs to — the project itself
27    /// when it is not a workspace member. This is where its `Cargo.lock` lives.
28    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(&current_dir, framework, mode)
52            .await
53            .map_err(|error| error.to_string())
54    })
55    .boxed()
56    .shared()
57}
58
59/// Represents a `WaterUI` project with its manifest and crate information.
60#[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    /// Select or update a framework channel and persist its exact dependency selection.
73    ///
74    /// # Errors
75    /// Returns an error when resolution, native-project merging, or dependency verification fails.
76    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    /// Run the `WaterUI` project on the specified device.
130    ///
131    /// This method handles building, packaging, and running the project.
132    ///
133    /// # Arguments
134    /// - `backend`: The backend to use for building and packaging
135    /// - `platform`: The target platform to build for
136    /// - `device`: The device to run on
137    ///
138    /// # Errors
139    /// - If any step in the build, package, or run process fails.
140    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    /// Run the `WaterUI` project with explicit run options.
151    ///
152    /// This allows callers (like preview) to inject extra environment variables.
153    ///
154    /// # Errors
155    /// Returns an error if building, packaging, or launching the app fails.
156    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        // Build rust library for the target platform
164        backend
165            .build(
166                self,
167                platform,
168                BuildOptions::development(BuildProfile::Debug),
169            )
170            .await
171            .map_err(FailToRun::Build)?;
172
173        // Package the build artifacts for the target platform
174        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    /// Run the Android backend for the specific target ABI of the device.
183    ///
184    /// This is required because Android packaging is ABI-dependent (e.g., `x86_64` emulator vs
185    /// `arm64-v8a` physical device).
186    ///
187    /// # Errors
188    /// Returns an error if building, packaging, or launching the Android app fails.
189    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    /// Get the root path of the project.
232    ///
233    /// Same as the directory containing `Water.toml`.
234    #[must_use]
235    pub fn root(&self) -> &Path {
236        &self.root
237    }
238
239    /// Get the target directory for Rust build artifacts.
240    ///
241    /// # Errors
242    ///
243    /// Returns an error when Cargo metadata cannot resolve the target directory.
244    pub async fn target_dir(&self) -> eyre::Result<PathBuf> {
245        Ok(self.cargo_layout().await?.target_dir)
246    }
247
248    /// The lockfile the application builds against.
249    ///
250    /// The workspace `Cargo.lock` when the project is a workspace member,
251    /// otherwise the project's own. It need not exist yet: a project that has
252    /// never been resolved has none.
253    ///
254    /// # Errors
255    ///
256    /// Returns an error when Cargo metadata cannot resolve the workspace.
257    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    /// Resolve the Cargo target directory every generated backend crate builds into.
269    ///
270    /// Generated backends need an explicit target directory, because the default one
271    /// would land inside the managed build cache next to the generated sources — and
272    /// those sources are deleted and regenerated whenever the CLI's scaffold templates
273    /// change. Compiled artifacts do not become stale for that reason, so keeping them
274    /// there meant one CLI upgrade discarded the compiled dependency graph of every
275    /// project on the machine.
276    ///
277    /// One directory serves every backend, platform, and feature set of a linkage:
278    /// Cargo already keys each compiled unit by target triple, resolved features, and
279    /// profile, so switching backends only rebuilds the units the two graphs do not
280    /// share — measured on an example app, over 80% of the Apple FFI graph resolves
281    /// identically to the Hydrolysis graph and is reused as-is. Builds must therefore
282    /// agree on everything Cargo hashes into every unit — pass an explicit `--target`
283    /// and keep final-artifact link flags out of `RUSTFLAGS` (see
284    /// `RustBuild::with_final_rustc_arg`) — or two variants sharing this directory
285    /// re-fingerprint each other's entire dependency graph on every switch.
286    ///
287    /// Linkage is the one axis Cargo cannot separate: shared-runtime development
288    /// builds carry `-Cprefer-dynamic -Crpath` in `RUSTFLAGS` and static packaging
289    /// builds carry none, so each linkage keeps its own directory instead of the two
290    /// variants invalidating each other whenever a developer alternates `water run`
291    /// and `water package`.
292    ///
293    /// # Errors
294    ///
295    /// Returns an error when Cargo metadata cannot resolve the project target directory.
296    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    /// Resolve an isolated target directory for a backend built by a different Rust
309    /// toolchain.
310    ///
311    /// Cargo hashes the compiler into every unit fingerprint, so a backend that pins
312    /// its own toolchain (ESP32's Espressif Rust fork) would invalidate the host
313    /// units of [`Self::water_target_dir`] on every switch if it shared the directory.
314    ///
315    /// # Errors
316    ///
317    /// Returns an error when Cargo metadata cannot resolve the project target directory.
318    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    /// Get the backends configured for the project.
327    #[must_use]
328    pub const fn backends(&self) -> &Backends {
329        &self.manifest.backends
330    }
331
332    /// Get the crate name of the project.
333    #[must_use]
334    pub const fn crate_name(&self) -> &CrateName {
335        &self.crate_name
336    }
337
338    /// Get configured or default FFI crate name for app mode.
339    #[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    /// Get configured preview wrapper crate name for preview dylib builds.
347    #[must_use]
348    pub fn preview_ffi_crate_name(&self) -> CrateName {
349        self.crate_name.with_suffix("preview-ffi")
350    }
351
352    /// Get the crate root path used to build preview dylibs.
353    #[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    /// Get the crate name used to build preview dylibs.
359    #[must_use]
360    pub fn preview_dylib_crate_name(&self) -> CrateName {
361        self.preview_ffi_crate_name()
362    }
363
364    /// Get configured or default GTK backend crate name for app mode.
365    #[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    /// Get configured or default hydrolysis backend crate name for app mode.
373    #[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    /// Get the generated ESP32 firmware harness crate name.
381    #[must_use]
382    pub fn esp32_backend_crate_name(&self) -> CrateName {
383        self.crate_name.with_suffix("esp32")
384    }
385
386    /// Get the crate name of the generated experimental TUI launcher.
387    #[must_use]
388    pub fn tui_backend_crate_name(&self) -> CrateName {
389        self.crate_name.with_suffix("tui")
390    }
391
392    /// Get package type declared in `Water.toml`.
393    #[must_use]
394    pub const fn package_type(&self) -> PackageType {
395        self.manifest.package.package_type
396    }
397
398    /// Returns true when this project is a playground project.
399    #[must_use]
400    pub fn is_playground(&self) -> bool {
401        self.package_type() == PackageType::Playground
402    }
403
404    /// Get the Apple backend configuration if available.
405    #[must_use]
406    pub const fn apple_backend(&self) -> Option<&AppleBackend> {
407        self.manifest.backends.apple()
408    }
409
410    /// Get the full path to a backend directory.
411    ///
412    /// Returns `project.root() / backends.path / B::DEFAULT_PATH`.
413    #[must_use]
414    pub fn backend_path<B: Backend>(&self) -> PathBuf {
415        self.managed_backends_root.join(B::DEFAULT_PATH)
416    }
417
418    /// Get the relative path to a backend directory from project root.
419    ///
420    /// Returns `backends.path / B::DEFAULT_PATH`.
421    #[must_use]
422    pub fn backend_relative_path<B: Backend>(&self) -> PathBuf {
423        self.manifest.backends.path().join(B::DEFAULT_PATH)
424    }
425
426    /// Get the full path to the managed native FFI companion crate.
427    #[must_use]
428    pub fn ffi_crate_path(&self) -> PathBuf {
429        self.managed_backends_root.join("ffi")
430    }
431
432    /// Directory name this project's preview module occupies inside a workspace.
433    #[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    /// Get the full path to the managed preview-only companion crate.
440    ///
441    /// The crate lives inside the support runtime's workspace rather than this
442    /// project's build cache, because a preview module and the runtime it is
443    /// loaded into must come out of one Cargo resolution to agree on the
444    /// `-C metadata` hash mangled into every symbol.
445    #[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    /// Get the relative path to the managed native FFI companion crate from project root.
451    #[must_use]
452    pub fn ffi_crate_relative_path(&self) -> PathBuf {
453        self.manifest.backends.path().join("ffi")
454    }
455
456    /// Get the Android backend configuration if available.
457    #[must_use]
458    pub const fn android_backend(&self) -> Option<&AndroidBackend> {
459        self.manifest.backends.android()
460    }
461
462    /// Get the GTK4 backend configuration if available.
463    #[must_use]
464    pub const fn gtk4_backend(&self) -> Option<&crate::gtk4::backend::Gtk4Backend> {
465        self.manifest.backends.gtk4()
466    }
467
468    /// Get the hydrolysis backend configuration if available.
469    #[must_use]
470    pub const fn hydrolysis_backend(
471        &self,
472    ) -> Option<&crate::hydrolysis::backend::HydrolysisBackend> {
473        self.manifest.backends.hydrolysis()
474    }
475
476    /// Get the ESP32 backend configuration if available.
477    #[must_use]
478    pub const fn esp32_backend(&self) -> Option<&crate::esp32::backend::Esp32Backend> {
479        self.manifest.backends.esp32()
480    }
481
482    /// Get the manifest of the project.
483    #[must_use]
484    pub const fn manifest(&self) -> &Manifest {
485        &self.manifest
486    }
487
488    /// The framework this project resolves generated code against — the
489    /// channel selection `Water.toml` records, or the checkout `waterui_path`
490    /// names.
491    ///
492    /// # Errors
493    ///
494    /// Returns an error when the manifest records no framework source, or the
495    /// local checkout's framework facts cannot be read.
496    pub async fn resolved_framework(&self) -> eyre::Result<ResolvedFramework> {
497        ResolvedFramework::for_manifest(self.manifest(), &self.root).await
498    }
499
500    /// Returns whether the packaged application links `package_name`.
501    ///
502    /// Development-only and build-only dependencies are excluded because they
503    /// do not become part of the packaged application. The resolved graph is
504    /// cached so backend regeneration and scaffolding share one Cargo metadata
505    /// resolution.
506    ///
507    /// # Errors
508    ///
509    /// Returns an error when Cargo cannot resolve the application graph or
510    /// omits a package referenced by that graph.
511    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    /// Whether the application's graph turns on the standard `WebView`
530    /// component.
531    ///
532    /// The signal is a `webview` feature enabled inside the application's own
533    /// subtree — the facade's `webview` feature, or an engine crate's `webview`
534    /// hookup — resolved by `cargo tree --edges features`. The
535    /// `waterui-webview` *package* cannot be the signal: engines link it for
536    /// the shared asset-server types a `ChromiumPage` answers over CDP
537    /// (#586), so its presence no longer means the component is used.
538    ///
539    /// # Errors
540    ///
541    /// Returns an error when Cargo cannot resolve the application graph or
542    /// omits a package referenced by that graph.
543    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    /// Resolve and validate the standard `WebView` engine for a build.
562    ///
563    /// The application's own dependency graph is the selection: linking
564    /// `waterui-browser-cef` or `waterui-browser-wpe` picks that engine, and an
565    /// app that links neither uses whatever web engine the target platform
566    /// bridges. Nothing in `Water.toml` names an engine, because nothing else
567    /// could keep the packaged runtime and the code that loads it in step.
568    ///
569    /// Returns `None` when no `webview` feature is enabled in the
570    /// application's graph, so an engine crate reaching the graph through some
571    /// other component never adds a `WebView` runtime to the package on its
572    /// own.
573    ///
574    /// # Errors
575    ///
576    /// Returns an error when Cargo metadata cannot be resolved, when the
577    /// application links two engines at once, or when the selected engine is
578    /// unsupported for the requested platform and backend.
579    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    /// The browser engine crate the application links, if any.
596    ///
597    /// # Errors
598    ///
599    /// Returns an error when Cargo metadata cannot be resolved, or when the
600    /// application links more than one engine — two engines cannot both draw
601    /// one `WebView`, and the second `install` would fail at startup.
602    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    /// Resolves and validates every embedded browser runtime linked by the application.
617    ///
618    /// # Errors
619    ///
620    /// Returns an error when standard `WebView` or Chromium is unsupported for
621    /// the requested platform and backend.
622    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    /// Get the bundle identifier of the project.
639    #[must_use]
640    pub const fn bundle_identifier(&self) -> &BundleIdentifier {
641        &self.manifest.package.bundle_identifier
642    }
643
644    /// Get the assets directory path relative to project root.
645    #[must_use]
646    pub fn assets_path(&self) -> &str {
647        &self.manifest.package.assets_path
648    }
649
650    /// Get the full path to the assets directory.
651    #[must_use]
652    pub fn assets_dir(&self) -> PathBuf {
653        self.root.join(&self.manifest.package.assets_path)
654    }
655
656    /// Clean build artifacts for the project using the specified backend.
657    ///
658    /// # Errors
659    ///
660    /// Returns an error if cleaning fails.
661    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    /// Clean all build artifacts for the project.
670    ///
671    /// This cleans:
672    /// - Rust target directory
673    /// - Apple build artifacts (if backend configured)
674    /// - Android build artifacts (if backend configured)
675    /// - GTK4 build artifacts (if backend configured)
676    ///
677    /// # Errors
678    ///
679    /// Returns an error if any cleaning operation fails.
680    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            // A playground's Cargo target directory is the user's own (often a
690            // workspace-wide one), so only the CLI-owned `water-backends` subtree
691            // is removed — including target directories older CLI layouts left
692            // behind — never the user's other compiled artifacts.
693            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        // Clean Rust target directory
701        let target_dir = self.target_dir().await?;
702        if target_dir.exists() {
703            smol::fs::remove_dir_all(&target_dir).await?;
704        }
705
706        // Clean Apple backend if configured
707        if self.apple_backend().is_some() {
708            clean_apple(self).await?;
709        }
710
711        // Clean Android backend if configured
712        if self.android_backend().is_some() {
713            clean_android(self).await?;
714        }
715
716        // Clean GTK4 backend if configured
717        if self.gtk4_backend().is_some() || (self.is_playground() && cfg!(target_os = "linux")) {
718            clean_gtk4(self).await?;
719        }
720
721        // Clean hydrolysis backend if configured
722        if self.hydrolysis_backend().is_some() || self.is_playground() {
723            clean_hydrolysis(self).await?;
724        }
725
726        // Clean ESP32 backend if configured
727        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    /// Package the project for the specified platform.
740    ///
741    /// # Errors
742    ///
743    /// Returns an error if packaging fails.
744    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/// Errors that can occur when opening a `WaterUI` project.
759#[derive(Debug, thiserror::Error)]
760pub enum FailToOpenProject {
761    /// Failed to open the Water.toml manifest.
762    #[error("Failed to open project manifest: {0}")]
763    Manifest(FailToOpenManifest),
764    /// Failed to read the Cargo.toml file.
765    #[error("Failed to read Cargo.toml: {0}")]
766    CargoManifest(cargo_toml::Error),
767
768    /// Failed to get Cargo metadata.
769    #[error("Failed to get Cargo metadata: {0}")]
770    TargetDirError(#[from] cargo_metadata::Error),
771
772    /// The selected framework could not be validated for this CLI.
773    #[error("Framework compatibility check failed: {0}")]
774    Framework(eyre::Report),
775    /// The project's `[patch]` tables could not be brought in line with the
776    /// local checkout's.
777    #[error("Failed to refresh the [patch] tables from the local checkout: {0}")]
778    LocalPatches(eyre::Report),
779
780    /// Missing crate name in Cargo.toml.
781    #[error("Invalid Cargo.toml: missing crate name")]
782    MissingCrateName,
783
784    /// Crate name in Cargo.toml is invalid.
785    #[error("Invalid Cargo.toml crate name: {0}")]
786    InvalidCrateName(String),
787
788    /// Project permissions are not allowed in non-playground projects.
789    #[error("Project permissions are not allowed in non-playground projects")]
790    PermissionsNotAllowedInNonPlayground,
791
792    /// Backend-project configuration is not allowed in playground manifests.
793    #[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    /// Failed to initialize backend for playground project.
800    #[error("Failed to initialize backend: {0}")]
801    BackendInit(#[from] crate::backend::FailToInitBackend),
802
803    /// Failed to manage the global build cache directory.
804    #[error("Failed to prepare managed build cache: {0}")]
805    BuildCache(#[from] eyre::Report),
806}
807
808/// Errors that can occur when creating a new `WaterUI` project.
809#[derive(Debug, thiserror::Error)]
810pub enum FailToCreateProject {
811    /// Failed to resolve a coherent framework distribution.
812    #[error("Failed to resolve framework: {0}")]
813    Framework(eyre::Report),
814    /// The project directory already exists.
815    #[error("Directory already exists: {0}")]
816    DirectoryExists(PathBuf),
817    /// The directory is already a `WaterUI` project.
818    #[error("{0} is already a WaterUI project (Water.toml exists)")]
819    AlreadyProject(PathBuf),
820    /// The directory already contains a Cargo manifest that scaffolding
821    /// would overwrite.
822    #[error(
823        "{0} already contains a Cargo.toml; merge the generated scaffold manually or remove it first"
824    )]
825    CargoManifestExists(PathBuf),
826    /// Failed to create project directory.
827    #[error("Failed to create directory: {0}")]
828    CreateDir(std::io::Error),
829    /// Failed to scaffold project files.
830    #[error("Failed to scaffold project: {0}")]
831    Scaffold(std::io::Error),
832    /// Failed to save manifest.
833    #[error("Failed to save manifest: {0}")]
834    SaveManifest(#[from] FailToSaveManifest),
835
836    /// Failed to get Cargo metadata.
837    #[error("Failed to get Cargo metadata: {0}")]
838    TargetDirError(#[from] cargo_metadata::Error),
839
840    /// Failed to resolve the managed build cache path.
841    #[error("Failed to resolve managed build cache: {0}")]
842    BuildCache(#[from] eyre::Report),
843
844    /// Failed to initialize git repository.
845    #[error("Failed to initialize git repository: {0}")]
846    GitInit(std::io::Error),
847    /// Failed to check git repository status.
848    #[error("Failed to check git repository status: {0}")]
849    GitStatus(std::io::Error),
850}
851
852/// Options for creating a new `WaterUI` project.
853#[derive(Debug, Clone)]
854pub struct CreateOptions {
855    /// Application display name (e.g., "Water Example").
856    pub name: String,
857    /// Bundle identifier (e.g., "dev.waterui.waterexample").
858    pub bundle_identifier: BundleIdentifier,
859    /// Package type for the project.
860    pub package_type: PackageType,
861    /// Path to local `WaterUI` repository for development.
862    pub waterui_path: Option<PathBuf>,
863    /// Framework channel, mutually exclusive with a local source path and a
864    /// manifest file.
865    pub channel: Option<FrameworkChannel>,
866    /// A certified `framework.json` on disk, mutually exclusive with a channel
867    /// and a local source path: the project pins the channel and revision the
868    /// manifest declares.
869    pub framework_manifest: Option<PathBuf>,
870    /// An already-resolved framework selection — how a support app inherits
871    /// the host project's framework exactly. Mutually exclusive with every
872    /// resolving source above.
873    pub framework: Option<ResolvedFramework>,
874    /// Author name for Cargo.toml.
875    pub author: String,
876    /// The declared web frontend: `Some` generates the `include_web!` root
877    /// view and writes `[web] package_manager`.
878    pub web: Option<WebScaffold>,
879}
880
881/// How `create`/`init` wires a declared web frontend into the scaffold.
882#[derive(Debug, Clone)]
883pub struct WebScaffold {
884    /// The package manager written to `[web] package_manager`.
885    pub package_manager: web::PackageManager,
886    /// The `include_web!` argument: `"web"` for the conventional layout, or a
887    /// path relative to the project root for a frontend referenced in place.
888    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    /// The framework the scaffold resolves against — always resolved: a
913    /// channel's certified release, a manifest file's, a caller-supplied
914    /// selection, or the checkout `waterui_path` names. A checkout's framework
915    /// is a filesystem source, so it is never persisted into `Water.toml`;
916    /// `waterui_path` itself is the record.
917    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    /// Scaffold this project's preview module inside `workspace_root`.
999    ///
1000    /// # Errors
1001    ///
1002    /// Returns an error when the generated crate cannot be written.
1003    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    /// Create a new `WaterUI` project at the specified path.
1048    ///
1049    /// This creates the project directory, scaffolds root files (Cargo.toml, src/lib.rs),
1050    /// and saves the Water.toml manifest. Use `init_apple_backend()` and `init_android_backend()`
1051    /// to scaffold platform backends after creation.
1052    ///
1053    /// # Errors
1054    /// - `FailToCreateProject::DirectoryExists`: If the directory already exists.
1055    /// - `FailToCreateProject::CreateDir`: If creating the directory fails.
1056    /// - `FailToCreateProject::Scaffold`: If scaffolding files fails.
1057    /// - `FailToCreateProject::SaveManifest`: If saving the manifest fails.
1058    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        // Check if directory already exists
1065        if path.exists() {
1066            return Err(FailToCreateProject::DirectoryExists(path));
1067        }
1068
1069        Self::scaffold_project(path, options).await
1070    }
1071
1072    /// Initialize a `WaterUI` project inside an existing directory
1073    /// (`water init`): the same scaffold as [`Project::create`] without the
1074    /// directory-creation step.
1075    ///
1076    /// # Errors
1077    /// - `FailToCreateProject::AlreadyProject`: If `Water.toml` already exists.
1078    /// - `FailToCreateProject::CargoManifestExists`: If `Cargo.toml` already
1079    ///   exists and would be overwritten.
1080    /// - the [`Project::create`] scaffold errors.
1081    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        // Derive crate name from display name
1100        let crate_name = options.crate_name()?;
1101        let (framework, lockfile) = options
1102            .resolve_framework()
1103            .await
1104            .map_err(FailToCreateProject::Framework)?;
1105
1106        // Framework validation precedes directory creation so a rejected
1107        // local checkout leaves nothing behind; on `init` the directory
1108        // already exists and this is a no-op.
1109        smol::fs::create_dir_all(&path)
1110            .await
1111            .map_err(FailToCreateProject::CreateDir)?;
1112
1113        // Build template context for root files
1114        let ctx = TemplateContext::for_create_options(&options, crate_name.clone(), &framework);
1115
1116        // The assets root is derived once and shared with both the scaffold and
1117        // the manifest, so the created directory and `Water.toml` cannot disagree.
1118        let assets_path = default_assets_path();
1119
1120        // Scaffold root files (Cargo.toml, src/lib.rs, .gitignore, assets/README.md)
1121        templates::root::scaffold(&path, &ctx, &assets_path)
1122            .await
1123            .map_err(FailToCreateProject::Scaffold)?;
1124
1125        // `.mcp.json` lets MCP clients launched in the project root find
1126        // `water mcp` without any user configuration.
1127        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        // Build manifest
1145        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            // A local checkout's framework is a filesystem source — never
1164            // persisted; `waterui_path` above is the record.
1165            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        // Save Water.toml
1176        manifest.save(&path).await?;
1177
1178        // Initialize git repository if not already in one
1179        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    /// Ensure the project is initialized with git.
1212    ///
1213    /// Checks if the project directory is already part of a git repository.
1214    /// If not, initializes a new git repository.
1215    async fn ensure_git_init(path: &Path) -> Result<(), FailToCreateProject> {
1216        // Check if already in a git repository
1217
1218        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            // Initialize a new git repository
1231            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    /// Initialize the Apple backend for this project.
1244    ///
1245    /// This scaffolds the Apple backend files and updates the manifest.
1246    ///
1247    /// # Errors
1248    /// Returns an error if scaffolding fails.
1249    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    /// Initialize the Android backend for this project.
1263    ///
1264    /// This scaffolds the Android backend files and updates the manifest.
1265    ///
1266    /// # Errors
1267    /// Returns an error if scaffolding fails.
1268    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    /// Initialize the GTK4 backend for an existing project.
1282    ///
1283    /// Creates necessary files/folders for the GTK4 backend under `backend_path::<Gtk4Backend>()`.
1284    ///
1285    /// # Errors
1286    /// Returns an error if scaffolding fails.
1287    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    /// Initialize the hydrolysis backend for an existing project.
1306    ///
1307    /// Creates necessary files/folders for the hydrolysis backend under
1308    /// `backend_path::<HydrolysisBackend>()`.
1309    ///
1310    /// # Errors
1311    /// Returns an error if scaffolding fails.
1312    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        // The Hydrolysis backend is what `water mcp` drives, so adding it is
1325        // what makes the project MCP-servable; the file is user-owned and
1326        // only written when absent.
1327        crate::mcp::ensure_mcp_json(&self.root).await?;
1328        Ok(())
1329    }
1330
1331    /// Initialize the ESP32 backend for an existing project.
1332    ///
1333    /// Creates necessary files/folders for the ESP32 firmware harness under
1334    /// `backend_path::<Esp32Backend>()`.
1335    ///
1336    /// # Errors
1337    /// Returns an error if scaffolding fails.
1338    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    /// Select the ESP32 target chip, persisting it to `Water.toml`.
1351    ///
1352    /// The chip is the single source of truth for the ESP32 backend's target
1353    /// triple, QEMU model, and firmware parameters. Selecting a platform such
1354    /// as `esp32c3` calls this so the generated harness and build target follow
1355    /// the platform. No-ops (and skips the manifest write) when the configured
1356    /// chip already matches.
1357    ///
1358    /// # Errors
1359    /// Returns an error if saving the manifest fails.
1360    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    /// Remove Apple backend configuration and generated files.
1373    ///
1374    /// # Errors
1375    /// Returns an error if deleting files or saving manifest fails.
1376    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    /// Remove Android backend configuration and generated files.
1387    ///
1388    /// # Errors
1389    /// Returns an error if deleting files or saving manifest fails.
1390    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    /// Remove GTK4 backend configuration and generated files.
1401    ///
1402    /// # Errors
1403    /// Returns an error if deleting files or saving manifest fails.
1404    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    /// Remove hydrolysis backend configuration and generated files.
1414    ///
1415    /// # Errors
1416    /// Returns an error if deleting files or saving manifest fails.
1417    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    /// Remove ESP32 backend configuration and generated files.
1427    ///
1428    /// # Errors
1429    /// Returns an error if deleting files or saving manifest fails.
1430    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    /// Open a `WaterUI` project located at the specified path.
1440    ///
1441    /// This loads both the `Water.toml` manifest and the `Cargo.toml` file.
1442    /// For playground projects, backends are automatically initialized if not configured.
1443    ///
1444    /// # Errors
1445    /// - `FailToOpenProject::Manifest`: If there was an error opening the `Water.toml` manifest.
1446    /// - `FailToOpenProject::CargoManifest`: If there was an error reading the `Cargo.toml` file.
1447    /// - `FailToOpenProject::MissingCrateName`: If the crate name is missing in `Cargo.toml`.
1448    pub async fn open(path: impl AsRef<Path>) -> Result<Self, FailToOpenProject> {
1449        Self::open_with_mode(path, OpenMode::Full).await
1450    }
1451
1452    /// Open a project for preview dylib builds without initializing native app backends.
1453    ///
1454    /// Playground preview dylib builds only need the managed preview wrapper crate. Native
1455    /// backend initialization is reserved for support app projects that actually launch apps.
1456    ///
1457    /// # Errors
1458    /// - `FailToOpenProject::Manifest`: If there was an error opening the `Water.toml` manifest.
1459    /// - `FailToOpenProject::CargoManifest`: If there was an error reading the `Cargo.toml` file.
1460    /// - `FailToOpenProject::MissingCrateName`: If the crate name is missing in `Cargo.toml`.
1461    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    /// Make a local-checkout project's `[patch]` tables the checkout's.
1466    ///
1467    /// Cargo applies `[patch]` only from the workspace it builds, so a project
1468    /// on a `waterui_path` carries a copy of the checkout's tables, and the
1469    /// copy has to follow the checkout: a fork pin moves, an entry is added or
1470    /// dropped, and a project scaffolded earlier would otherwise build a graph
1471    /// the checkout no longer produces, silently. The manifest is rewritten
1472    /// only when the tables differ, so an up-to-date project stays untouched.
1473    ///
1474    /// A project that is itself a member of the checkout's workspace — every
1475    /// example and playground in this repository — needs no copy, because the
1476    /// tables Cargo reads are the checkout's own. Writing one anyway put a
1477    /// `[patch.crates-io]` table into a member manifest, where Cargo ignores it
1478    /// and says so on every single build.
1479    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                // Cargo reads `[patch]` from `patch_root` and nothing this
1490                // function writes into the project could change that, so the
1491                // honest move is to say which manifest the tables belong in
1492                // rather than write a copy that is read by nobody.
1493                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, &current, &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        // Check that permissions are only set for playground projects
1579        if !is_playground && !manifest.permissions.is_empty() {
1580            return Err(FailToOpenProject::PermissionsNotAllowedInNonPlayground);
1581        }
1582
1583        // Playgrounds delegate backend projects to the CLI, so backend
1584        // scaffolding configuration is rejected. `[backends.esp32]` is the
1585        // exception: it is device configuration (chip, panel geometry,
1586        // bundled fonts) only the app author can supply, and its harness
1587        // still lives in the managed build cache.
1588        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        // For playground projects, auto-initialize backends
1629        // Always re-scaffold templates on each run to pick up manifest changes (e.g., permissions)
1630        // Build cache (build/, .gradle/, DerivedData/) is preserved since scaffold only writes template files
1631        //
1632        // Skip backend initialization when:
1633        // 1. Running inside Xcode's sandboxed build script phase (WATERUI_SKIP_RUST_BUILD=1)
1634        // 2. Running inside any sandbox (sandbox-exec sets __XCODE_BUILT_PRODUCTS_DIR_PATHS or similar)
1635        // 3. Xcode is the current build tool (ACTION env var is set by Xcode)
1636        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() // Xcode sets this during builds
1639            || 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
1802/// Run `cargo tree` for the application package rooted at `project_root`'s
1803/// manifest, over the given edge kinds, and return the `{p}`-formatted tree.
1804async fn cargo_tree(project_root: &Path, edges: &str) -> eyre::Result<String> {
1805    // `dunce`, not `std::fs::canonicalize`: on Windows the standard one returns
1806    // an extended-length path (`\\?\D:\...`), while `cargo metadata` reports the
1807    // plain one, so comparing the two never matched and the package below was
1808    // always "omitted" (part of #152). Canonicalize before invoking metadata,
1809    // not just on the looked-up side: metadata echoes the manifest path it is
1810    // given, so under a symlinked `TMPDIR` (`/var` → `/private/var` on macOS)
1811    // a non-canonical input can never match what metadata reports.
1812    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
1875/// Feature names turned on inside the application's subtree. With `--edges
1876/// features`, `cargo tree` reports each enabled feature as a
1877/// `<package> feature "<name>"` node; only the names are kept, since the
1878/// question asked of this set is always "is a feature named X enabled".
1879async 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/// Configuration for a `WaterUI` project persisted to `Water.toml`.
1917#[derive(Debug, Serialize, Deserialize, Clone)]
1918pub struct Manifest {
1919    /// Package information.
1920    pub package: Package,
1921    /// Backend configurations for various platforms.
1922    #[serde(default, skip_serializing_if = "Backends::is_empty")]
1923    pub backends: Backends,
1924    /// Web engine selected for the standard `WebView` component.
1925    /// Path to local `WaterUI` repository for dev mode.
1926    /// When set, all backends will use this path instead of the published versions.
1927    #[serde(skip_serializing_if = "Option::is_none")]
1928    pub waterui_path: Option<String>,
1929    /// Exact framework and backend selection, resolved only by explicit version operations.
1930    #[serde(default, skip_serializing_if = "Option::is_none")]
1931    pub framework: Option<ResolvedFramework>,
1932    /// Permission configuration for playground projects.
1933    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1934    pub permissions: BTreeMap<PermissionKey, PermissionEntry>,
1935    /// App-only configuration.
1936    #[serde(default, skip_serializing_if = "Option::is_none")]
1937    pub app: Option<AppConfig>,
1938    /// Cross-platform app theme slots.
1939    #[serde(default, skip_serializing_if = "Option::is_none")]
1940    pub theme: Option<ThemeConfig>,
1941    /// The launch screen shown until the app's first frame.
1942    #[serde(default, skip_serializing_if = "Option::is_none")]
1943    pub launch: Option<LaunchConfig>,
1944    /// Web-frontend toolchain declarations (`[web]`); only the CLI reads this.
1945    #[serde(default, skip_serializing_if = "Option::is_none")]
1946    pub web: Option<web::WebConfig>,
1947}
1948
1949/// Permission entry for playground projects.
1950#[derive(Debug, Serialize, Deserialize, Clone)]
1951pub struct PermissionEntry {
1952    enable: bool,
1953    /// Explain why this permission is needed.
1954    description: String,
1955}
1956
1957impl PermissionEntry {
1958    /// Check if this permission is enabled.
1959    #[must_use]
1960    pub const fn is_enabled(&self) -> bool {
1961        self.enable
1962    }
1963
1964    /// Get the description of why this permission is needed.
1965    #[must_use]
1966    pub fn description(&self) -> &str {
1967        &self.description
1968    }
1969}
1970
1971/// Errors that can occur when opening a `Water.toml` manifest file.
1972#[derive(Debug, thiserror::Error)]
1973pub enum FailToOpenManifest {
1974    /// Failed to read the manifest file from the filesystem.
1975    #[error("Failed to read manifest file: {0}")]
1976    ReadError(std::io::Error),
1977    /// The manifest file is invalid or malformed.
1978    #[error("Invalid manifest file: {0}")]
1979    InvalidManifest(toml::de::Error),
1980
1981    /// The manifest file was not found at the specified path.
1982    #[error("Manifest file not found at the specified path")]
1983    NotFound,
1984}
1985
1986/// Errors that can occur when saving a `Water.toml` manifest file.
1987#[derive(Debug, thiserror::Error)]
1988pub enum FailToSaveManifest {
1989    /// Failed to serialize the manifest to TOML.
1990    #[error("Failed to serialize manifest: {0}")]
1991    Serialize(toml::ser::Error),
1992    /// Failed to write the manifest file to disk.
1993    #[error("Failed to write manifest file: {0}")]
1994    Write(std::io::Error),
1995}
1996impl Manifest {
1997    /// Open and parse a `Water.toml` manifest file from the specified path.
1998    ///
1999    /// # Errors
2000    /// - `FailToOpenManifest::ReadError`: If there was an error reading the file.
2001    /// - `FailToOpenManifest::InvalidManifest`: If the file contents are not valid TOML.
2002    /// - `FailToOpenManifest::NotFound`: If the file does not exist at the specified path.
2003    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    /// Save the manifest to a `Water.toml` file at the specified directory.
2015    ///
2016    /// # Errors
2017    /// - If there was an error serializing the manifest to TOML.
2018    /// - If there was an error writing the file.
2019    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    /// Create a new `Manifest` with the specified package information.
2028    #[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/// The engine that draws this application's standard `WebView`.
2045#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2046pub enum ResolvedWebViewBackend {
2047    /// Platform-provided `WebView`.
2048    System,
2049    /// Bundled WPE `WebKit` runtime.
2050    Wpe,
2051    /// Bundled Chromium Embedded Framework runtime.
2052    Cef,
2053}
2054
2055/// Browser engines that must be staged for one resolved application graph.
2056#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2057pub struct BrowserRuntimePlan {
2058    /// Standard `WebView` engine, when `waterui-webview` is linked.
2059    pub webview: Option<ResolvedWebViewBackend>,
2060    /// Whether the independent full Chromium component is linked.
2061    pub chromium: bool,
2062}
2063
2064impl BrowserRuntimePlan {
2065    /// Returns whether this application requires a packaged CEF runtime and
2066    /// subprocess helper.
2067    #[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    /// Return whether this engine can be hosted by a platform and backend pair.
2075    #[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    /// Returns this engine, or an error naming what cannot host it.
2102    ///
2103    /// # Errors
2104    ///
2105    /// Returns an error when this platform and backend pair cannot host the
2106    /// engine the application selected.
2107    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    /// Stable lowercase name used for Cargo features, runtime manifests, and diagnostics.
2124    #[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/// Error returned for an unsupported `WebView` engine/platform/backend combination.
2143#[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/// App-specific configuration in `Water.toml`.
2157#[derive(Debug, Serialize, Deserialize, Clone, Default)]
2158pub struct AppConfig {
2159    /// Optional crate name overrides.
2160    #[serde(default, skip_serializing_if = "Option::is_none")]
2161    pub crates: Option<AppCrates>,
2162}
2163
2164/// Crate name overrides for app mode.
2165#[derive(Debug, Serialize, Deserialize, Clone, Default)]
2166pub struct AppCrates {
2167    /// Optional override crate name for generated FFI crate.
2168    #[serde(default, skip_serializing_if = "Option::is_none")]
2169    pub ffi: Option<CrateName>,
2170    /// Optional override crate name for generated GTK backend crate.
2171    #[serde(default, skip_serializing_if = "Option::is_none")]
2172    pub gtk: Option<CrateName>,
2173    /// Optional override crate name for generated hydrolysis backend crate.
2174    #[serde(default, skip_serializing_if = "Option::is_none")]
2175    pub hydrolysis: Option<CrateName>,
2176}
2177
2178/// `[package]` section in `Water.toml`.
2179#[derive(Debug, Serialize, Deserialize, Clone)]
2180pub struct Package {
2181    /// Type of the package (e.g., "app").
2182    #[serde(rename = "type")]
2183    pub package_type: PackageType,
2184    /// Human-readable name of the application (e.g., "Water Demo").
2185    pub name: String,
2186    /// Bundle identifier for the application (e.g., "dev.waterui.waterdemo").
2187    pub bundle_identifier: BundleIdentifier,
2188    /// Path to assets directory relative to project root. Defaults to "assets".
2189    #[serde(
2190        default = "default_assets_path",
2191        skip_serializing_if = "is_default_assets_path"
2192    )]
2193    pub assets_path: String,
2194    /// Whether to build as an accessory (headless) app on macOS.
2195    #[serde(default, skip_serializing_if = "is_false")]
2196    pub accessory: bool,
2197}
2198
2199/// Reads the `package.name` of a project's `Cargo.toml` — the crate name the
2200/// generated backends and preview symbols build on.
2201///
2202/// Lighter than [`Project::open`]: this only parses the manifest, so callers
2203/// that need just the crate name (the `water preview`/`water mcp` entry
2204/// points) do not pay for a full project open.
2205///
2206/// # Errors
2207/// Returns an error if `Cargo.toml` cannot be read or has no `package.name`.
2208pub 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
2220/// Whether two paths name the same directory on disk.
2221///
2222/// Compared after canonicalization, because the two sides come from different
2223/// places — one walked up from the project, one joined from a relative
2224/// `waterui_path` — and `examples/filter/../..` is the repository root however
2225/// it is spelled.
2226fn 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/// Package type indicating what kind of project this is.
2244#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq)]
2245#[serde(rename_all = "lowercase")]
2246pub enum PackageType {
2247    /// A standalone application with platform-specific backends.
2248    #[default]
2249    App,
2250    /// A playground project for quick experimentation.
2251    /// Platform projects are created in a temporary directory.
2252    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    /// An application that links no engine crate uses whatever the platform
2366    /// bridges, and the bridge is not everywhere: Linux outside GTK has none, so
2367    /// such a build is refused with an explanation instead of producing a
2368    /// contentless web view at runtime.
2369    #[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    /// The engine is read out of the application's own graph, so the examples
2465    /// are the test: the CEF `WebView` example links `waterui-browser-cef` and
2466    /// the shared system-`WebView` example links no engine at all. The
2467    /// examples live in the framework repository — this crate builds against a
2468    /// pinned `water-rs/waterui` revision, and the test clones it on demand.
2469    #[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        // The Chromium example links the engine it draws through, and nothing
2483        // else: no second engine, and no `waterui` facade `webview` feature.
2484        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        // A Chromium-only application shows no standard `WebView`, so
2493        // `webview_enabled` must be false for it: the Apple scaffold reads this
2494        // graph to decide whether to link the `WaterUICefWebView` framework.
2495        // The `waterui-webview` package is present — `waterui-chromium` links
2496        // it for the shared asset-server types — so the signal is the `webview`
2497        // feature, which nothing in this subtree turns on.
2498        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    /// The `map` capability — the Apple `MapKit` bridge's `-DWATERUI_MAP`, and
2552    /// the FFI's `map` feature — is read off the application's own graph, the
2553    /// same way the browser engine is. `waterui-map` is a component crate an
2554    /// application depends on directly; no facade feature announces it any
2555    /// more, so linking it is what the capability has to see. The examples
2556    /// live in the framework repository — this crate builds against a pinned
2557    /// `water-rs/waterui` revision, and the test clones it on demand.
2558    #[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    /// The documented `assets!` workflow requires the assets root to exist: the
2589    /// planner walks it recursively, so a missing directory fails the first
2590    /// `assets!` call. `water create` must therefore produce it, tracked, and at
2591    /// exactly the path the generated `Water.toml` declares.
2592    #[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                // A channel resolution would fetch the newest release from
2608                // GitHub; a unit test resolves a fixture in place instead.
2609                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    /// A project on a `waterui_path` mirrors the checkout's `[patch]` tables
2641    /// every time it opens: entries the checkout dropped disappear, moved ones
2642    /// follow, and a project already in line is left byte-for-byte alone.
2643    #[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    /// A project inside the checkout's own workspace — every example in this
2690    /// repository — is already governed by the checkout's tables, so nothing is
2691    /// copied into the member manifest, where Cargo would ignore it and warn on
2692    /// every build.
2693    #[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    /// A project inside someone else's workspace cannot carry the tables at all:
2718    /// Cargo reads them from that workspace root. Saying which manifest they
2719    /// belong in beats writing a copy that is read by nobody.
2720    #[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}