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::{BuildProgress, RustLinkage};
9use crate::framework::{
10    FrameworkChannel, ResolvedFramework, validate_local_cli, validate_resolved_cli,
11};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14enum OpenMode {
15    Full,
16    PreviewBuild,
17}
18
19/// 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        // A configured backend whose scaffold packages the target channel
96        // withholds could never be regenerated — refuse the switch before a
97        // manifest is rewritten.
98        for (configured, backend) in [
99            (previous.backends.gtk4().is_some(), TargetBackend::Gtk4),
100            (
101                previous.backends.hydrolysis().is_some(),
102                TargetBackend::Hydrolysis,
103            ),
104            (previous.backends.winui().is_some(), TargetBackend::WinUi),
105            (previous.backends.esp32().is_some(), TargetBackend::Dew),
106        ] {
107            if configured {
108                for package in backend.scaffold_packages() {
109                    framework.require_distributable(package)?;
110                }
111            }
112        }
113        let mut next = previous.clone();
114        next.waterui_path = None;
115        next.framework = Some(framework.clone());
116        let mut updates =
117            templates::framework_updates(&path, &previous, &next, &crate_name).await?;
118        framework.update_manifest(&mut cargo, &templates::project_patches(&path, &previous)?)?;
119        water.remove("waterui_path");
120        water["framework"] =
121            toml_edit::Item::Table(toml_edit::ser::to_document(&framework)?.into_table());
122        updates.push((water_path, water.to_string().into_bytes()));
123        updates.push((cargo_path, cargo.to_string().into_bytes()));
124        if let Some(lockfile) = lockfile {
125            updates.push((
126                path.join("Cargo.lock"),
127                framework.cargo_lock(&lockfile)?.to_string().into_bytes(),
128            ));
129            updates.push((path.join("Water.lock"), lockfile));
130        }
131        let mut updates: Vec<_> = updates
132            .into_iter()
133            .map(|(file, contents)| (file, Some(contents)))
134            .collect();
135        if channel == FrameworkChannel::Stable
136            && previous
137                .framework
138                .as_ref()
139                .is_some_and(|previous| previous.channel() != Some(FrameworkChannel::Stable))
140        {
141            updates.push((path.join("Water.lock"), None));
142        }
143        apply_channel_selection(&path, framework, updates).await?;
144        Self::open_for_preview_build(path).await.map_err(Into::into)
145    }
146
147    /// Run the `WaterUI` project on the specified device.
148    ///
149    /// This method handles building, packaging, and running the project.
150    ///
151    /// # Arguments
152    /// - `backend`: The backend to use for building and packaging
153    /// - `platform`: The target platform to build for
154    /// - `device`: The device to run on
155    ///
156    /// # Errors
157    /// - If any step in the build, package, or run process fails.
158    pub async fn run<B: Backend, D: Device>(
159        &self,
160        backend: &B,
161        platform: TargetPlatform,
162        device: D,
163    ) -> Result<Running, FailToRun> {
164        self.run_with_options(backend, platform, device, RunOptions::new(), None)
165            .await
166    }
167
168    /// Run the `WaterUI` project with explicit run options.
169    ///
170    /// This allows callers (like preview) to inject extra environment variables.
171    /// `progress`, when given, receives cargo compile events from both the
172    /// library build and the packaging pass's asset-manifest compile.
173    ///
174    /// # Errors
175    /// Returns an error if building, packaging, or launching the app fails.
176    pub async fn run_with_options<B: Backend, D: Device>(
177        &self,
178        backend: &B,
179        platform: TargetPlatform,
180        device: D,
181        run_options: RunOptions,
182        progress: Option<BuildProgress>,
183    ) -> Result<Running, FailToRun> {
184        let mut build_options = BuildOptions::development(BuildProfile::Debug);
185        if let Some(progress) = &progress {
186            build_options = build_options.with_progress(progress.clone());
187        }
188        // Build rust library for the target platform
189        backend
190            .build(self, platform, build_options)
191            .await
192            .map_err(FailToRun::Build)?;
193
194        let mut package_options = PackageOptions::development();
195        if let Some(progress) = progress {
196            package_options = package_options.with_progress(progress);
197        }
198        // Package the build artifacts for the target platform
199        let artifact = backend
200            .package(self, platform, package_options)
201            .await
202            .map_err(FailToRun::Package)?;
203
204        Self::run_packaged(device, artifact, run_options).await
205    }
206
207    /// Run the Android backend for the specific target ABI of the device.
208    ///
209    /// This is required because Android packaging is ABI-dependent (e.g., `x86_64` emulator vs
210    /// `arm64-v8a` physical device).
211    ///
212    /// `build_options` decides the Rust runtime linkage: a support app that
213    /// `dlopen`s `WaterUI` modules (the preview app) must pass
214    /// [`BuildOptions::with_dynamic_module_loading`] so the shared runtime is
215    /// built and packaged; a standalone app links it in.
216    ///
217    /// # Errors
218    /// Returns an error if building, packaging, or launching the Android app fails.
219    pub async fn run_android_with_options<D: Device + AndroidAbiProvider>(
220        &self,
221        _backend: &AndroidBackend,
222        device: D,
223        run_options: RunOptions,
224        build_options: BuildOptions,
225        progress: Option<BuildProgress>,
226    ) -> Result<Running, FailToRun> {
227        let abi = device.android_abi();
228
229        self.browser_runtime_plan(TargetPlatform::Android, TargetBackend::Android)
230            .await
231            .map_err(FailToRun::Build)?;
232
233        AndroidPlatform::clean_jni_libs(self)
234            .await
235            .map_err(FailToRun::Build)?;
236
237        let mut build_options = build_options;
238        if let Some(progress) = &progress {
239            build_options = build_options.with_progress(progress.clone());
240        }
241        AndroidPlatform::new(abi)
242            .build(self, build_options)
243            .await
244            .map_err(FailToRun::Build)?;
245
246        let mut package_options = PackageOptions::development();
247        if let Some(progress) = progress {
248            package_options = package_options.with_progress(progress);
249        }
250        let artifact = AndroidPlatform::package_with_abis(self, package_options, &[abi])
251            .await
252            .map_err(FailToRun::Package)?;
253
254        Self::run_packaged(device, artifact, run_options).await
255    }
256
257    async fn run_packaged<D: Device>(
258        device: D,
259        artifact: Artifact,
260        run_options: RunOptions,
261    ) -> Result<Running, FailToRun> {
262        info!("Running on device");
263
264        let running = device
265            .run(&crate::toolchain::Host::current(), artifact, run_options)
266            .await?;
267        Ok(running)
268    }
269
270    /// Get the root path of the project.
271    ///
272    /// Same as the directory containing `Water.toml`.
273    #[must_use]
274    pub fn root(&self) -> &Path {
275        &self.root
276    }
277
278    /// Get the target directory for Rust build artifacts.
279    ///
280    /// # Errors
281    ///
282    /// Returns an error when Cargo metadata cannot resolve the target directory.
283    pub async fn target_dir(&self) -> eyre::Result<PathBuf> {
284        Ok(self.cargo_layout().await?.target_dir)
285    }
286
287    /// The lockfile the application builds against.
288    ///
289    /// The workspace `Cargo.lock` when the project is a workspace member,
290    /// otherwise the project's own. It need not exist yet: a project that has
291    /// never been resolved has none.
292    ///
293    /// # Errors
294    ///
295    /// Returns an error when Cargo metadata cannot resolve the workspace.
296    pub async fn lockfile_path(&self) -> eyre::Result<PathBuf> {
297        Ok(self.cargo_layout().await?.workspace_root.join("Cargo.lock"))
298    }
299
300    async fn cargo_layout(&self) -> eyre::Result<CargoLayout> {
301        self.cargo_layout
302            .clone()
303            .await
304            .map_err(|error| eyre::eyre!(error))
305    }
306
307    /// Resolve the Cargo target directory every generated backend crate builds into.
308    ///
309    /// The directory is shared by every project on the machine — one
310    /// `~/.water/build_cache/target` subtree — because Cargo already keys each
311    /// compiled unit by target triple, resolved features, and profile: a second
312    /// project's build reuses the dependency graph the first one compiled
313    /// instead of cold-building it, the way sccache-equipped machines behave.
314    /// The shared root sits beside the per-project managed containers rather
315    /// than inside one: generated backend sources are deleted and regenerated
316    /// whenever the CLI's scaffold templates change, while compiled artifacts
317    /// do not become stale for that reason — keeping them together meant one
318    /// CLI upgrade discarded the compiled dependency graph of every project on
319    /// the machine.
320    ///
321    /// One directory serves every backend, platform, and feature set of a
322    /// linkage: switching backends only rebuilds the units the two graphs do
323    /// not share — measured on an example app, over 80% of the Apple FFI graph
324    /// resolves identically to the Hydrolysis graph and is reused as-is.
325    /// Builds must therefore agree on everything Cargo hashes into every unit —
326    /// pass an explicit `--target` and keep final-artifact link flags out of
327    /// `RUSTFLAGS` (see `RustBuild::with_final_rustc_arg`) — or two variants
328    /// sharing this directory re-fingerprint each other's entire dependency
329    /// graph on every switch.
330    ///
331    /// Linkage is the one axis Cargo cannot separate: shared-runtime development
332    /// builds carry `-Cprefer-dynamic -Crpath` in `RUSTFLAGS` and static packaging
333    /// builds carry none, so each linkage keeps its own directory instead of the two
334    /// variants invalidating each other whenever a developer alternates `water run`
335    /// and `water package`.
336    ///
337    /// # Errors
338    ///
339    /// Returns an error when the shared build-cache directory cannot be resolved.
340    pub async fn water_target_dir(&self, linkage: RustLinkage) -> eyre::Result<PathBuf> {
341        let variant = match linkage {
342            RustLinkage::SharedRuntime => "shared",
343            RustLinkage::Static => "static",
344        };
345        Ok(crate::water_dir::shared_target_dir().await?.join(variant))
346    }
347
348    /// Resolve an isolated target directory for a backend built by a different Rust
349    /// toolchain.
350    ///
351    /// Cargo hashes the compiler into every unit fingerprint, so a backend that pins
352    /// its own toolchain (ESP32's Espressif Rust fork) would invalidate the host
353    /// units of [`Self::water_target_dir`] on every switch if it shared the directory.
354    ///
355    /// # Errors
356    ///
357    /// Returns an error when the shared build-cache directory cannot be resolved.
358    pub async fn toolchain_target_dir(&self, toolchain: &str) -> eyre::Result<PathBuf> {
359        Ok(crate::water_dir::shared_target_dir()
360            .await?
361            .join(format!("toolchain-{toolchain}")))
362    }
363
364    /// Resolve the target directory the project's host-side rlib builds into.
365    ///
366    /// `build_host_rlib` compiles the user crate for the host to read its
367    /// `waterui_meta_*` symbols. That compile shares the dependency graph with
368    /// every other project's host build, so it lives beside the backend
369    /// variants in the shared target root rather than in the project's own
370    /// `target/`.
371    ///
372    /// # Errors
373    ///
374    /// Returns an error when the shared build-cache directory cannot be resolved.
375    pub async fn host_target_dir(&self) -> eyre::Result<PathBuf> {
376        crate::water_dir::shared_host_target_dir().await
377    }
378
379    /// Get the backends configured for the project.
380    #[must_use]
381    pub const fn backends(&self) -> &Backends {
382        &self.manifest.backends
383    }
384
385    /// Get the crate name of the project.
386    #[must_use]
387    pub const fn crate_name(&self) -> &CrateName {
388        &self.crate_name
389    }
390
391    /// Get configured or default FFI crate name for app mode.
392    ///
393    /// The default is tagged with this project's root — see
394    /// [`generated_crate_name`]; an explicit `[crates]` override is verbatim.
395    #[must_use]
396    pub fn ffi_crate_name(&self) -> CrateName {
397        self.app_crate_overrides()
398            .and_then(|crates| crates.ffi.clone())
399            .unwrap_or_else(|| generated_crate_name(&self.crate_name, "ffi", &self.root))
400    }
401
402    /// Get configured preview wrapper crate name for preview dylib builds.
403    #[must_use]
404    pub fn preview_ffi_crate_name(&self) -> CrateName {
405        generated_crate_name(&self.crate_name, "preview-ffi", &self.root)
406    }
407
408    /// Get the crate root path used to build preview dylibs.
409    #[must_use]
410    pub fn preview_dylib_crate_path(&self, workspace_root: &Path) -> PathBuf {
411        self.preview_ffi_crate_path(workspace_root)
412    }
413
414    /// Get the crate name used to build preview dylibs.
415    #[must_use]
416    pub fn preview_dylib_crate_name(&self) -> CrateName {
417        self.preview_ffi_crate_name()
418    }
419
420    /// Get configured or default GTK backend crate name for app mode.
421    #[must_use]
422    pub fn gtk_backend_crate_name(&self) -> CrateName {
423        self.app_crate_overrides()
424            .and_then(|crates| crates.gtk.clone())
425            .unwrap_or_else(|| generated_crate_name(&self.crate_name, "gtk4", &self.root))
426    }
427
428    /// Get configured or default hydrolysis backend crate name for app mode.
429    #[must_use]
430    pub fn hydrolysis_backend_crate_name(&self) -> CrateName {
431        self.app_crate_overrides()
432            .and_then(|crates| crates.hydrolysis.clone())
433            .unwrap_or_else(|| generated_crate_name(&self.crate_name, "hydrolysis", &self.root))
434    }
435
436    /// Get configured or default `WinUI` backend crate name for app mode.
437    #[must_use]
438    pub fn winui_backend_crate_name(&self) -> CrateName {
439        self.app_crate_overrides()
440            .and_then(|crates| crates.winui.clone())
441            .unwrap_or_else(|| generated_crate_name(&self.crate_name, "winui", &self.root))
442    }
443
444    /// Get the generated ESP32 firmware harness crate name.
445    #[must_use]
446    pub fn esp32_backend_crate_name(&self) -> CrateName {
447        generated_crate_name(&self.crate_name, "esp32", &self.root)
448    }
449
450    /// Get the crate name of the generated experimental TUI launcher.
451    #[must_use]
452    pub fn tui_backend_crate_name(&self) -> CrateName {
453        generated_crate_name(&self.crate_name, "tui", &self.root)
454    }
455
456    /// The executable name a packaged backend binary ships under: the
457    /// configured `[crates]` override verbatim, or `<crate>-<suffix>` when
458    /// the crate is generated.
459    ///
460    /// [`generated_crate_name`]'s project-root tag exists to keep a shared
461    /// Cargo target directory unambiguous; it is internal to the build and
462    /// must never name a shipped executable.
463    fn shipped_backend_binary_name(
464        &self,
465        suffix: &str,
466        configured: Option<&CrateName>,
467    ) -> CrateName {
468        configured
469            .cloned()
470            .unwrap_or_else(|| self.crate_name.with_suffix(suffix))
471    }
472
473    /// The executable name the packaged GTK4 binary ships under.
474    #[must_use]
475    pub fn gtk4_binary_name(&self) -> CrateName {
476        self.shipped_backend_binary_name(
477            "gtk4",
478            self.app_crate_overrides()
479                .and_then(|crates| crates.gtk.as_ref()),
480        )
481    }
482
483    /// The executable name the packaged hydrolysis binary ships under.
484    #[must_use]
485    pub fn hydrolysis_binary_name(&self) -> CrateName {
486        self.shipped_backend_binary_name(
487            "hydrolysis",
488            self.app_crate_overrides()
489                .and_then(|crates| crates.hydrolysis.as_ref()),
490        )
491    }
492
493    /// The executable name the packaged `WinUI` binary ships under.
494    #[must_use]
495    pub fn winui_binary_name(&self) -> CrateName {
496        self.shipped_backend_binary_name(
497            "winui",
498            self.app_crate_overrides()
499                .and_then(|crates| crates.winui.as_ref()),
500        )
501    }
502
503    /// The name the packaged ESP32 firmware image ships under.
504    #[must_use]
505    pub fn esp32_binary_name(&self) -> CrateName {
506        self.shipped_backend_binary_name("esp32", None)
507    }
508
509    /// Get package type declared in `Water.toml`.
510    #[must_use]
511    pub const fn package_type(&self) -> PackageType {
512        self.manifest.package.package_type
513    }
514
515    /// Returns true when this project is a playground project.
516    #[must_use]
517    pub fn is_playground(&self) -> bool {
518        self.package_type() == PackageType::Playground
519    }
520
521    /// Get the Apple backend configuration if available.
522    #[must_use]
523    pub const fn apple_backend(&self) -> Option<&AppleBackend> {
524        self.manifest.backends.apple()
525    }
526
527    /// Get the full path to a backend directory.
528    ///
529    /// Returns `project.root() / backends.path / B::DEFAULT_PATH`.
530    #[must_use]
531    pub fn backend_path<B: Backend>(&self) -> PathBuf {
532        self.managed_backends_root.join(B::DEFAULT_PATH)
533    }
534
535    /// Get the relative path to a backend directory from project root.
536    ///
537    /// Returns `backends.path / B::DEFAULT_PATH`.
538    #[must_use]
539    pub fn backend_relative_path<B: Backend>(&self) -> PathBuf {
540        self.manifest.backends.path().join(B::DEFAULT_PATH)
541    }
542
543    /// Get the full path to the managed native FFI companion crate.
544    #[must_use]
545    pub fn ffi_crate_path(&self) -> PathBuf {
546        self.managed_backends_root.join("ffi")
547    }
548
549    /// Directory name this project's preview module occupies inside a workspace.
550    #[must_use]
551    pub fn preview_module_member_path(&self) -> PathBuf {
552        Path::new(crate::templates::PREVIEW_MODULES_DIR)
553            .join(self.preview_ffi_crate_name().to_string())
554    }
555
556    /// Get the full path to the managed preview-only companion crate.
557    ///
558    /// The crate lives inside the support runtime's workspace rather than this
559    /// project's build cache, because a preview module and the runtime it is
560    /// loaded into must come out of one Cargo resolution to agree on the
561    /// `-C metadata` hash mangled into every symbol.
562    #[must_use]
563    pub fn preview_ffi_crate_path(&self, workspace_root: &Path) -> PathBuf {
564        workspace_root.join(self.preview_module_member_path())
565    }
566
567    /// Get the relative path to the managed native FFI companion crate from project root.
568    #[must_use]
569    pub fn ffi_crate_relative_path(&self) -> PathBuf {
570        self.manifest.backends.path().join("ffi")
571    }
572
573    /// Get the Android backend configuration if available.
574    #[must_use]
575    pub const fn android_backend(&self) -> Option<&AndroidBackend> {
576        self.manifest.backends.android()
577    }
578
579    /// Get the GTK4 backend configuration if available.
580    #[must_use]
581    pub const fn gtk4_backend(&self) -> Option<&crate::gtk4::backend::Gtk4Backend> {
582        self.manifest.backends.gtk4()
583    }
584
585    /// Get the hydrolysis backend configuration if available.
586    #[must_use]
587    pub const fn hydrolysis_backend(
588        &self,
589    ) -> Option<&crate::hydrolysis::backend::HydrolysisBackend> {
590        self.manifest.backends.hydrolysis()
591    }
592
593    /// Get the `WinUI` backend configuration if available.
594    #[must_use]
595    pub const fn winui_backend(&self) -> Option<&crate::winui::backend::WinUiBackend> {
596        self.manifest.backends.winui()
597    }
598
599    /// Get the ESP32 backend configuration if available.
600    #[must_use]
601    pub const fn esp32_backend(&self) -> Option<&crate::esp32::backend::Esp32Backend> {
602        self.manifest.backends.esp32()
603    }
604
605    /// Get the manifest of the project.
606    #[must_use]
607    pub const fn manifest(&self) -> &Manifest {
608        &self.manifest
609    }
610
611    /// The framework this project resolves generated code against — the
612    /// channel selection `Water.toml` records, or the checkout `waterui_path`
613    /// names.
614    ///
615    /// # Errors
616    ///
617    /// Returns an error when the manifest records no framework source, or the
618    /// local checkout's framework facts cannot be read.
619    pub async fn resolved_framework(&self) -> eyre::Result<ResolvedFramework> {
620        ResolvedFramework::for_manifest(self.manifest(), &self.root).await
621    }
622
623    /// Assert the selected framework channel distributes every scaffold
624    /// package `backend` links — the git-pinned experimental set `stable`
625    /// withholds. Runs before the backend writes a file, so a withheld
626    /// package fails the init with the channel fix rather than partway
627    /// through the generated tree.
628    async fn require_distributable_backend(
629        &self,
630        backend: TargetBackend,
631    ) -> Result<(), crate::backend::FailToInitBackend> {
632        let packages = backend.scaffold_packages();
633        if packages.is_empty() {
634            return Ok(());
635        }
636        let framework = self
637            .resolved_framework()
638            .await
639            .map_err(crate::backend::FailToInitBackend::Config)?;
640        for package in packages {
641            framework
642                .require_distributable(package)
643                .map_err(crate::backend::FailToInitBackend::Config)?;
644        }
645        Ok(())
646    }
647
648    /// Returns whether the packaged application links `package_name`.
649    ///
650    /// Development-only and build-only dependencies are excluded because they
651    /// do not become part of the packaged application. The resolved graph is
652    /// cached so backend regeneration and scaffolding share one Cargo metadata
653    /// resolution.
654    ///
655    /// # Errors
656    ///
657    /// Returns an error when Cargo cannot resolve the application graph or
658    /// omits a package referenced by that graph.
659    pub async fn links_runtime_package(&self, package_name: &str) -> eyre::Result<bool> {
660        let project_root = self.root.clone();
661        let cargo_layout = self.cargo_layout.clone();
662        let packages = self
663            .linked_packages
664            .get_or_init(|| async move {
665                cargo_layout.await?;
666                resolve_linked_runtime_packages(project_root, false)
667                    .await
668                    .map_err(|error| error.to_string())
669            })
670            .await;
671        match packages {
672            Ok(packages) => Ok(packages.contains_key(package_name)),
673            Err(error) => Err(eyre::eyre!(error.clone())),
674        }
675    }
676
677    /// Whether the application's graph turns on the standard `WebView`
678    /// component.
679    ///
680    /// The signal is a `webview` feature enabled inside the application's own
681    /// subtree — the facade's `webview` feature, or an engine crate's `webview`
682    /// hookup — resolved by `cargo tree --edges features`. The
683    /// `waterui-webview` *package* cannot be the signal: engines link it for
684    /// the shared asset-server types a `ChromiumPage` answers over CDP
685    /// (#586), so its presence no longer means the component is used.
686    ///
687    /// # Errors
688    ///
689    /// Returns an error when Cargo cannot resolve the application graph or
690    /// omits a package referenced by that graph.
691    pub async fn uses_standard_webview(&self) -> eyre::Result<bool> {
692        let project_root = self.root.clone();
693        let cargo_layout = self.cargo_layout.clone();
694        let features = self
695            .enabled_features
696            .get_or_init(|| async move {
697                cargo_layout.await?;
698                resolve_enabled_features(project_root, false)
699                    .await
700                    .map_err(|error| error.to_string())
701            })
702            .await;
703        match features {
704            Ok(features) => Ok(features.contains("webview")),
705            Err(error) => Err(eyre::eyre!(error.clone())),
706        }
707    }
708
709    /// Resolve and validate the standard `WebView` engine for a build.
710    ///
711    /// The application's own dependency graph is the selection: linking
712    /// `waterui-browser-cef` or `waterui-browser-wpe` picks that engine, and an
713    /// app that links neither uses whatever web engine the target platform
714    /// bridges. Nothing in `Water.toml` names an engine, because nothing else
715    /// could keep the packaged runtime and the code that loads it in step.
716    ///
717    /// Returns `None` when no `webview` feature is enabled in the
718    /// application's graph, so an engine crate reaching the graph through some
719    /// other component never adds a `WebView` runtime to the package on its
720    /// own.
721    ///
722    /// # Errors
723    ///
724    /// Returns an error when Cargo metadata cannot be resolved, when the
725    /// application links two engines at once, or when the selected engine is
726    /// unsupported for the requested platform and backend.
727    pub async fn resolved_webview_backend(
728        &self,
729        platform: TargetPlatform,
730        backend: TargetBackend,
731    ) -> eyre::Result<Option<ResolvedWebViewBackend>> {
732        if !self.uses_standard_webview().await? {
733            return Ok(None);
734        }
735        let engine = self.linked_browser_engine().await?;
736        engine
737            .unwrap_or(ResolvedWebViewBackend::System)
738            .validate(platform, backend)
739            .map(Some)
740            .map_err(Into::into)
741    }
742
743    /// The browser engine crate the application links, if any.
744    ///
745    /// # Errors
746    ///
747    /// Returns an error when Cargo metadata cannot be resolved, or when the
748    /// application links more than one engine — two engines cannot both draw
749    /// one `WebView`, and the second `install` would fail at startup.
750    pub async fn linked_browser_engine(&self) -> eyre::Result<Option<ResolvedWebViewBackend>> {
751        let cef = self.links_runtime_package("waterui-browser-cef").await?;
752        let wpe = self.links_runtime_package("waterui-browser-wpe").await?;
753        match (cef, wpe) {
754            (true, true) => eyre::bail!(
755                "the application links both waterui-browser-cef and waterui-browser-wpe; \
756                 exactly one browser engine can draw a WebView"
757            ),
758            (true, false) => Ok(Some(ResolvedWebViewBackend::Cef)),
759            (false, true) => Ok(Some(ResolvedWebViewBackend::Wpe)),
760            (false, false) => Ok(None),
761        }
762    }
763
764    /// Whether the generated backend manifests declare the CEF subprocess
765    /// helper `[[bin]]`.
766    ///
767    /// This is the manifest's own predicate: the helper exists only when the
768    /// application links the CEF engine crate, while a `waterui-chromium`
769    /// link alone does not declare it. Builds and packaging that touch the
770    /// helper must gate on this rather than
771    /// [`BrowserRuntimePlan::requires_cef`], which is wider — it also turns
772    /// on for chromium — and would request a bin target Cargo never
773    /// received.
774    ///
775    /// # Errors
776    ///
777    /// Returns an error when Cargo metadata cannot be resolved or the
778    /// application links two engines at once.
779    pub async fn declares_cef_helper(&self) -> eyre::Result<bool> {
780        Ok(crate::project_types::declares_cef_helper(
781            self.linked_browser_engine().await?,
782        ))
783    }
784
785    /// Resolves and validates every embedded browser runtime linked by the application.
786    ///
787    /// # Errors
788    ///
789    /// Returns an error when standard `WebView` or Chromium is unsupported for
790    /// the requested platform and backend.
791    pub async fn browser_runtime_plan(
792        &self,
793        platform: TargetPlatform,
794        backend: TargetBackend,
795    ) -> eyre::Result<BrowserRuntimePlan> {
796        let webview = self.resolved_webview_backend(platform, backend).await?;
797        let chromium = self.links_runtime_package("waterui-chromium").await?;
798        if chromium && !cef_is_supported(platform, backend) {
799            eyre::bail!(
800                "waterui-chromium requires CEF, which is unsupported for platform {platform:?} \
801                 with backend {backend:?}"
802            );
803        }
804        Ok(BrowserRuntimePlan { webview, chromium })
805    }
806
807    /// Get the bundle identifier of the project.
808    #[must_use]
809    pub const fn bundle_identifier(&self) -> &BundleIdentifier {
810        &self.manifest.package.bundle_identifier
811    }
812
813    /// Get the assets directory path relative to project root.
814    #[must_use]
815    pub fn assets_path(&self) -> &str {
816        &self.manifest.package.assets_path
817    }
818
819    /// Get the full path to the assets directory.
820    #[must_use]
821    pub fn assets_dir(&self) -> PathBuf {
822        self.root.join(&self.manifest.package.assets_path)
823    }
824
825    /// Clean build artifacts for the project using the specified backend.
826    ///
827    /// # Errors
828    ///
829    /// Returns an error if cleaning fails.
830    pub async fn clean<B: Backend>(
831        &self,
832        backend: &B,
833        platform: TargetPlatform,
834    ) -> Result<(), eyre::Report> {
835        backend.clean(self, platform).await
836    }
837
838    /// Clean all build artifacts for the project.
839    ///
840    /// This cleans:
841    /// - Rust target directory
842    /// - Apple build artifacts (if backend configured)
843    /// - Android build artifacts (if backend configured)
844    /// - GTK4 build artifacts (if backend configured)
845    ///
846    /// # Errors
847    ///
848    /// Returns an error if any cleaning operation fails.
849    pub async fn clean_all(&self) -> Result<(), eyre::Report> {
850        use crate::{
851            android::platform::clean_android, apple::platform::clean_apple,
852            esp32::platform::clean_esp32, gtk4::platform::clean_gtk4,
853            hydrolysis::platform::clean_hydrolysis, winui::platform::clean_winui,
854        };
855
856        if self.is_playground() {
857            crate::water_dir::remove_project_build_cache(self.root()).await?;
858            // Compiled artifacts live in the per-user shared target directory
859            // and outlive any single project, so they stay. What remains to
860            // sweep here is the `water-backends` subtree older CLI layouts
861            // left under the project's own Cargo target directory — never the
862            // user's other compiled artifacts.
863            let water_backends_root = self.target_dir().await?.join("water-backends");
864            if water_backends_root.exists() {
865                smol::fs::remove_dir_all(&water_backends_root).await?;
866            }
867            return Ok(());
868        }
869
870        // Clean Rust target directory
871        let target_dir = self.target_dir().await?;
872        if target_dir.exists() {
873            smol::fs::remove_dir_all(&target_dir).await?;
874        }
875
876        // Clean Apple backend if configured
877        if self.apple_backend().is_some() {
878            clean_apple(self).await?;
879        }
880
881        // Clean Android backend if configured
882        if self.android_backend().is_some() {
883            clean_android(self).await?;
884        }
885
886        // Clean GTK4 backend if configured
887        if self.gtk4_backend().is_some() || (self.is_playground() && cfg!(target_os = "linux")) {
888            clean_gtk4(self).await?;
889        }
890
891        // Clean hydrolysis backend if configured
892        if self.hydrolysis_backend().is_some() || self.is_playground() {
893            clean_hydrolysis(self).await?;
894        }
895
896        // Clean `WinUI` backend if configured
897        if self.winui_backend().is_some() || (self.is_playground() && cfg!(target_os = "windows")) {
898            clean_winui(self).await?;
899        }
900
901        // Clean ESP32 backend if configured
902        if self.esp32_backend().is_some() {
903            clean_esp32(self).await?;
904        }
905
906        let ffi_target_dir = self.ffi_crate_path().join("target");
907        if ffi_target_dir.exists() {
908            smol::fs::remove_dir_all(&ffi_target_dir).await?;
909        }
910
911        Ok(())
912    }
913
914    /// Package the project for the specified platform.
915    ///
916    /// # Errors
917    ///
918    /// Returns an error if packaging fails.
919    pub async fn package<B: Backend>(
920        &self,
921        backend: &B,
922        platform: TargetPlatform,
923        options: PackageOptions,
924    ) -> Result<Artifact, eyre::Report> {
925        backend.package(self, platform, options).await
926    }
927
928    fn app_crate_overrides(&self) -> Option<&AppCrates> {
929        self.manifest.app.as_ref()?.crates.as_ref()
930    }
931}
932
933/// Errors that can occur when opening a `WaterUI` project.
934#[derive(Debug, thiserror::Error)]
935pub enum FailToOpenProject {
936    /// Failed to open the Water.toml manifest.
937    #[error("Failed to open project manifest: {0}")]
938    Manifest(FailToOpenManifest),
939    /// Failed to read the Cargo.toml file.
940    #[error("Failed to read Cargo.toml: {0}")]
941    CargoManifest(cargo_toml::Error),
942
943    /// Failed to get Cargo metadata.
944    #[error("Failed to get Cargo metadata: {0}")]
945    TargetDirError(#[from] cargo_metadata::Error),
946
947    /// The selected framework could not be validated for this CLI.
948    #[error("Framework compatibility check failed: {0}")]
949    Framework(eyre::Report),
950    /// The project's `[patch]` tables could not be brought in line with the
951    /// local checkout's.
952    #[error("Failed to refresh the [patch] tables from the local checkout: {0}")]
953    LocalPatches(eyre::Report),
954
955    /// Missing crate name in Cargo.toml.
956    #[error("Invalid Cargo.toml: missing crate name")]
957    MissingCrateName,
958
959    /// Crate name in Cargo.toml is invalid.
960    #[error("Invalid Cargo.toml crate name: {0}")]
961    InvalidCrateName(String),
962
963    /// Project permissions are not allowed in non-playground projects.
964    #[error("Project permissions are not allowed in non-playground projects")]
965    PermissionsNotAllowedInNonPlayground,
966
967    /// Backend-project configuration is not allowed in playground manifests.
968    #[error(
969        "Backend project configuration is not allowed in playground projects \
970         ([backends.esp32] device settings and `backend_path` source \
971         selections are the exceptions)"
972    )]
973    BackendsNotAllowedInPlayground,
974
975    /// Failed to initialize backend for playground project.
976    #[error("Failed to initialize backend: {0}")]
977    BackendInit(#[from] crate::backend::FailToInitBackend),
978
979    /// Failed to manage the global build cache directory.
980    #[error("Failed to prepare managed build cache: {0}")]
981    BuildCache(#[from] eyre::Report),
982}
983
984/// Errors that can occur when creating a new `WaterUI` project.
985#[derive(Debug, thiserror::Error)]
986pub enum FailToCreateProject {
987    /// Failed to resolve a coherent framework distribution.
988    #[error("Failed to resolve framework: {0}")]
989    Framework(eyre::Report),
990    /// The project directory already exists.
991    #[error("Directory already exists: {0}")]
992    DirectoryExists(PathBuf),
993    /// The directory is already a `WaterUI` project.
994    #[error("{0} is already a WaterUI project (Water.toml exists)")]
995    AlreadyProject(PathBuf),
996    /// The directory already contains a Cargo manifest that scaffolding
997    /// would overwrite.
998    #[error(
999        "{0} already contains a Cargo.toml; merge the generated scaffold manually or remove it first"
1000    )]
1001    CargoManifestExists(PathBuf),
1002    /// Failed to create project directory.
1003    #[error("Failed to create directory: {0}")]
1004    CreateDir(std::io::Error),
1005    /// Failed to scaffold project files.
1006    #[error("Failed to scaffold project: {0}")]
1007    Scaffold(std::io::Error),
1008    /// Failed to save manifest.
1009    #[error("Failed to save manifest: {0}")]
1010    SaveManifest(#[from] FailToSaveManifest),
1011
1012    /// Failed to get Cargo metadata.
1013    #[error("Failed to get Cargo metadata: {0}")]
1014    TargetDirError(#[from] cargo_metadata::Error),
1015
1016    /// Failed to resolve the managed build cache path.
1017    #[error("Failed to resolve managed build cache: {0}")]
1018    BuildCache(#[from] eyre::Report),
1019
1020    /// Failed to initialize git repository.
1021    #[error("Failed to initialize git repository: {0}")]
1022    GitInit(std::io::Error),
1023    /// Failed to check git repository status.
1024    #[error("Failed to check git repository status: {0}")]
1025    GitStatus(std::io::Error),
1026}
1027
1028/// Options for creating a new `WaterUI` project.
1029#[derive(Debug, Clone)]
1030pub struct CreateOptions {
1031    /// Application display name (e.g., "Water Example").
1032    pub name: String,
1033    /// Bundle identifier (e.g., "dev.waterui.waterexample").
1034    pub bundle_identifier: BundleIdentifier,
1035    /// Package type for the project.
1036    pub package_type: PackageType,
1037    /// Path to local `WaterUI` repository for development.
1038    pub waterui_path: Option<PathBuf>,
1039    /// Framework channel, mutually exclusive with a local source path and a
1040    /// manifest file.
1041    pub channel: Option<FrameworkChannel>,
1042    /// A certified `framework.json` on disk, mutually exclusive with a channel
1043    /// and a local source path: the project pins the channel and revision the
1044    /// manifest declares.
1045    pub framework_manifest: Option<PathBuf>,
1046    /// An already-resolved framework selection — how a support app inherits
1047    /// the host project's framework exactly. Mutually exclusive with every
1048    /// resolving source above.
1049    pub framework: Option<ResolvedFramework>,
1050    /// Author name for Cargo.toml.
1051    pub author: String,
1052    /// The backends the caller will scaffold after creation: each one's
1053    /// scaffold packages are held against the resolved channel before a
1054    /// file is written, so a package the channel withholds — the git-pinned
1055    /// experimental set — fails the create rather than the backend init.
1056    pub backends: Vec<TargetBackend>,
1057    /// The declared web frontend: `Some` generates the `include_web!` root
1058    /// view and writes `[web] package_manager`.
1059    pub web: Option<WebScaffold>,
1060}
1061
1062/// How `create`/`init` wires a declared web frontend into the scaffold.
1063#[derive(Debug, Clone)]
1064pub struct WebScaffold {
1065    /// The package manager written to `[web] package_manager`.
1066    pub package_manager: web::PackageManager,
1067    /// The `include_web!` argument: `"web"` for the conventional layout, or a
1068    /// path relative to the project root for a frontend referenced in place.
1069    pub include_arg: String,
1070}
1071
1072impl CreateOptions {
1073    fn crate_name(&self) -> Result<CrateName, FailToCreateProject> {
1074        let name = self
1075            .name
1076            .chars()
1077            .map(|character| {
1078                if character.is_alphanumeric() {
1079                    character.to_ascii_lowercase()
1080                } else {
1081                    '_'
1082                }
1083            })
1084            .collect::<String>();
1085        CrateName::try_from(name).map_err(|error| {
1086            FailToCreateProject::Scaffold(std::io::Error::new(
1087                std::io::ErrorKind::InvalidInput,
1088                error,
1089            ))
1090        })
1091    }
1092
1093    /// The framework the scaffold resolves against — always resolved: a
1094    /// channel's certified release, a manifest file's, a caller-supplied
1095    /// selection, or the checkout `waterui_path` names. A checkout's framework
1096    /// is a filesystem source, so it is never persisted into `Water.toml`;
1097    /// `waterui_path` itself is the record.
1098    async fn resolve_framework(&mut self) -> eyre::Result<(ResolvedFramework, Option<Vec<u8>>)> {
1099        let selected = [
1100            self.waterui_path.is_some(),
1101            self.channel.is_some(),
1102            self.framework_manifest.is_some(),
1103            self.framework.is_some(),
1104        ]
1105        .into_iter()
1106        .filter(|selected| *selected)
1107        .count();
1108        if selected > 1 {
1109            eyre::bail!(
1110                "a framework channel, a local source path, a framework manifest, \
1111                 and a resolved framework are mutually exclusive"
1112            );
1113        }
1114        if let Some(path) = &self.waterui_path {
1115            // `dunce`, not `std`'s canonicalize: on Windows the standard one
1116            // returns an extended-length path (`\\?\C:\…`), and a scaffolded
1117            // manifest that carries it as a dependency `path` is one Cargo
1118            // refuses to parse ("invalid path url").
1119            let path = path.clone();
1120            let root = unblock(move || dunce::canonicalize(path)).await?;
1121            self.waterui_path = Some(root.clone());
1122            return Ok((ResolvedFramework::for_local_checkout(&root).await?, None));
1123        }
1124        if let Some(path) = &self.framework_manifest {
1125            return ResolvedFramework::resolve_manifest(path).await;
1126        }
1127        if let Some(framework) = &self.framework {
1128            return Ok((framework.clone(), None));
1129        }
1130        ResolvedFramework::resolve(self.channel.unwrap_or_default()).await
1131    }
1132}
1133
1134impl Project {
1135    async fn scaffold_ffi_companion(&self) -> Result<(), crate::backend::FailToInitBackend> {
1136        let manifest = self.manifest();
1137        let app_name = manifest
1138            .package
1139            .name
1140            .chars()
1141            .filter(|c| c.is_alphanumeric())
1142            .collect::<String>();
1143        let webview_enabled = self
1144            .uses_standard_webview()
1145            .await
1146            .map_err(crate::backend::FailToInitBackend::Config)?;
1147        let chromium_enabled = self
1148            .links_runtime_package("waterui-chromium")
1149            .await
1150            .map_err(crate::backend::FailToInitBackend::Config)?;
1151        let browser_engine = self
1152            .linked_browser_engine()
1153            .await
1154            .map_err(crate::backend::FailToInitBackend::Config)?;
1155        let framework = self
1156            .resolved_framework()
1157            .await
1158            .map_err(crate::backend::FailToInitBackend::Config)?;
1159        let ctx = TemplateContext::for_project_manifest(
1160            manifest,
1161            self.crate_name().clone(),
1162            app_name,
1163            &framework,
1164        )
1165        .with_backend_project_path(self.ffi_crate_path())
1166        .with_project_root_path(self.root.clone())
1167        .with_webview_enabled(webview_enabled)
1168        .with_chromium_enabled(chromium_enabled)
1169        .with_browser_engine(browser_engine);
1170
1171        templates::ffi::scaffold(&self.ffi_crate_path(), &ctx, &self.ffi_crate_name())
1172            .await
1173            .map_err(crate::backend::FailToInitBackend::Io)?;
1174
1175        let lockfile = self
1176            .lockfile_path()
1177            .await
1178            .map_err(crate::backend::FailToInitBackend::Config)?;
1179        templates::ffi::seed_lockfile(&self.ffi_crate_path(), &lockfile)
1180            .await
1181            .map_err(crate::backend::FailToInitBackend::Io)
1182    }
1183
1184    /// Scaffold this project's preview module inside `workspace_root`.
1185    ///
1186    /// # Errors
1187    ///
1188    /// Returns an error when the generated crate cannot be written.
1189    pub async fn scaffold_preview_ffi_companion(
1190        &self,
1191        workspace_root: &Path,
1192    ) -> Result<PathBuf, crate::backend::FailToInitBackend> {
1193        let manifest = self.manifest();
1194        let app_name = manifest
1195            .package
1196            .name
1197            .chars()
1198            .filter(|c| c.is_alphanumeric())
1199            .collect::<String>();
1200        let framework = self
1201            .resolved_framework()
1202            .await
1203            .map_err(crate::backend::FailToInitBackend::Config)?;
1204        let ctx = TemplateContext::for_project_manifest(
1205            manifest,
1206            self.crate_name().clone(),
1207            app_name,
1208            &framework,
1209        )
1210        .with_backend_project_path(self.preview_ffi_crate_path(workspace_root))
1211        .with_project_root_path(self.root.clone());
1212
1213        let crate_path = self.preview_ffi_crate_path(workspace_root);
1214        templates::preview_ffi::scaffold(&crate_path, &ctx, &self.preview_ffi_crate_name())
1215            .await
1216            .map_err(crate::backend::FailToInitBackend::Io)?;
1217        Ok(crate_path)
1218    }
1219
1220    async fn remove_ffi_companion_if_unused(&self) -> eyre::Result<()> {
1221        if self.apple_backend().is_some() || self.android_backend().is_some() {
1222            return Ok(());
1223        }
1224
1225        let ffi_path = self.ffi_crate_path();
1226        if ffi_path.exists() {
1227            smol::fs::remove_dir_all(&ffi_path).await?;
1228        }
1229
1230        Ok(())
1231    }
1232
1233    /// Create a new `WaterUI` project at the specified path.
1234    ///
1235    /// This creates the project directory, scaffolds root files (Cargo.toml, src/lib.rs),
1236    /// and saves the Water.toml manifest. Use `init_apple_backend()` and `init_android_backend()`
1237    /// to scaffold platform backends after creation.
1238    ///
1239    /// # Errors
1240    /// - `FailToCreateProject::DirectoryExists`: If the directory already exists.
1241    /// - `FailToCreateProject::CreateDir`: If creating the directory fails.
1242    /// - `FailToCreateProject::Scaffold`: If scaffolding files fails.
1243    /// - `FailToCreateProject::SaveManifest`: If saving the manifest fails.
1244    pub async fn create(
1245        path: impl AsRef<Path>,
1246        options: CreateOptions,
1247    ) -> Result<Self, FailToCreateProject> {
1248        let path = path.as_ref().to_path_buf();
1249
1250        // Check if directory already exists
1251        if path.exists() {
1252            return Err(FailToCreateProject::DirectoryExists(path));
1253        }
1254
1255        Self::scaffold_project(path, options).await
1256    }
1257
1258    /// Initialize a `WaterUI` project inside an existing directory
1259    /// (`water init`): the same scaffold as [`Project::create`] without the
1260    /// directory-creation step.
1261    ///
1262    /// # Errors
1263    /// - `FailToCreateProject::AlreadyProject`: If `Water.toml` already exists.
1264    /// - `FailToCreateProject::CargoManifestExists`: If `Cargo.toml` already
1265    ///   exists and would be overwritten.
1266    /// - the [`Project::create`] scaffold errors.
1267    pub async fn init(
1268        path: impl AsRef<Path>,
1269        options: CreateOptions,
1270    ) -> Result<Self, FailToCreateProject> {
1271        let path = path.as_ref().to_path_buf();
1272        if path.join("Water.toml").exists() {
1273            return Err(FailToCreateProject::AlreadyProject(path));
1274        }
1275        if path.join("Cargo.toml").exists() {
1276            return Err(FailToCreateProject::CargoManifestExists(path));
1277        }
1278        Self::scaffold_project(path, options).await
1279    }
1280
1281    async fn scaffold_project(
1282        path: PathBuf,
1283        mut options: CreateOptions,
1284    ) -> Result<Self, FailToCreateProject> {
1285        // Derive crate name from display name
1286        let crate_name = options.crate_name()?;
1287        let (framework, lockfile) = options
1288            .resolve_framework()
1289            .await
1290            .map_err(FailToCreateProject::Framework)?;
1291
1292        // A backend whose scaffold packages the channel withholds cannot be
1293        // scaffolded at all — reject before a single file lands.
1294        for backend in &options.backends {
1295            for package in backend.scaffold_packages() {
1296                framework
1297                    .require_distributable(package)
1298                    .map_err(FailToCreateProject::Framework)?;
1299            }
1300        }
1301
1302        // Framework validation precedes directory creation so a rejected
1303        // local checkout leaves nothing behind; on `init` the directory
1304        // already exists and this is a no-op.
1305        smol::fs::create_dir_all(&path)
1306            .await
1307            .map_err(FailToCreateProject::CreateDir)?;
1308
1309        // Build template context for root files
1310        let ctx = TemplateContext::for_create_options(&options, crate_name.clone(), &framework);
1311
1312        // The assets root is derived once and shared with both the scaffold and
1313        // the manifest, so the created directory and `Water.toml` cannot disagree.
1314        let assets_path = default_assets_path();
1315
1316        // Scaffold root files (Cargo.toml, src/lib.rs, .gitignore, assets/README.md)
1317        templates::root::scaffold(&path, &ctx, &assets_path)
1318            .await
1319            .map_err(FailToCreateProject::Scaffold)?;
1320
1321        // `.mcp.json` lets MCP clients launched in the project root find
1322        // `water mcp` without any user configuration.
1323        crate::mcp::ensure_mcp_json(&path)
1324            .await
1325            .map_err(FailToCreateProject::Scaffold)?;
1326        if let Some(lockfile) = lockfile {
1327            let contents = ctx
1328                .framework
1329                .cargo_lock(&lockfile)
1330                .map_err(FailToCreateProject::Framework)?
1331                .to_string();
1332            smol::fs::write(path.join("Water.lock"), lockfile)
1333                .await
1334                .map_err(FailToCreateProject::Scaffold)?;
1335            smol::fs::write(path.join("Cargo.lock"), contents)
1336                .await
1337                .map_err(FailToCreateProject::Scaffold)?;
1338        }
1339
1340        // Build manifest
1341        let mut backends = Backends::default();
1342        if options.package_type == PackageType::App {
1343            backends.set_path("backends");
1344        }
1345
1346        let manifest = Manifest {
1347            package: Package {
1348                package_type: options.package_type,
1349                name: options.name.clone(),
1350                bundle_identifier: options.bundle_identifier.clone(),
1351                assets_path,
1352                accessory: false,
1353            },
1354            backends,
1355            waterui_path: options
1356                .waterui_path
1357                .as_ref()
1358                .map(|p| p.display().to_string()),
1359            // A local checkout's framework is a filesystem source — never
1360            // persisted; `waterui_path` above is the record.
1361            framework: framework.channel().is_some().then_some(framework),
1362            permissions: BTreeMap::default(),
1363            app: None,
1364            theme: None,
1365            launch: None,
1366            web: options.web.as_ref().map(|scaffold| web::WebConfig {
1367                package_manager: scaffold.package_manager,
1368            }),
1369        };
1370
1371        // Save Water.toml
1372        manifest.save(&path).await?;
1373
1374        // Initialize git repository if not already in one
1375        Self::ensure_git_init(&path).await?;
1376
1377        let managed_backends_root = if options.package_type == PackageType::Playground {
1378            crate::water_dir::project_build_cache_dir(&path)
1379                .await
1380                .map_err(FailToCreateProject::BuildCache)?
1381        } else {
1382            path.join(manifest.backends.path())
1383        };
1384
1385        let cargo_layout = if let Some(framework) = &manifest.framework {
1386            let layout =
1387                resolve_cargo_layout(&path, Some(framework.clone()), CargoResolution::Update)
1388                    .await
1389                    .map_err(FailToCreateProject::Framework)?;
1390            futures_util::future::ready(Ok::<CargoLayout, String>(layout))
1391                .boxed()
1392                .shared()
1393        } else {
1394            spawn_cargo_layout_resolution(&path, None, true)
1395        };
1396        Ok(Self {
1397            root: path,
1398            manifest,
1399            crate_name,
1400            cargo_layout,
1401            linked_packages: Arc::new(async_lock::OnceCell::new()),
1402            enabled_features: Arc::new(async_lock::OnceCell::new()),
1403            managed_backends_root,
1404        })
1405    }
1406
1407    /// Ensure the project is initialized with git.
1408    ///
1409    /// Checks if the project directory is already part of a git repository.
1410    /// If not, initializes a new git repository.
1411    async fn ensure_git_init(path: &Path) -> Result<(), FailToCreateProject> {
1412        // Check if already in a git repository
1413
1414        let mut cmd = Command::new("git");
1415
1416        let is_in_git = command(&mut cmd)
1417            .args(["rev-parse", "--git-dir"])
1418            .current_dir(path)
1419            .output()
1420            .await
1421            .map_err(FailToCreateProject::GitStatus)?
1422            .status
1423            .success();
1424
1425        if !is_in_git {
1426            // Initialize a new git repository
1427            let mut cmd = Command::new("git");
1428            command(&mut cmd)
1429                .args(["init"])
1430                .current_dir(path)
1431                .status()
1432                .await
1433                .map_err(FailToCreateProject::GitInit)?;
1434        }
1435
1436        Ok(())
1437    }
1438
1439    /// Initialize the Apple backend for this project.
1440    ///
1441    /// This scaffolds the Apple backend files and updates the manifest.
1442    ///
1443    /// # Errors
1444    /// Returns an error if scaffolding fails.
1445    pub async fn init_apple_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1446        use crate::backend::Backend;
1447
1448        let backend = AppleBackend::init(self).await?;
1449        self.scaffold_ffi_companion().await?;
1450        self.manifest.backends.set_apple(backend);
1451        self.manifest
1452            .save(&self.root)
1453            .await
1454            .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1455        Ok(())
1456    }
1457
1458    /// Initialize the Android backend for this project.
1459    ///
1460    /// This scaffolds the Android backend files and updates the manifest.
1461    ///
1462    /// # Errors
1463    /// Returns an error if scaffolding fails.
1464    pub async fn init_android_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1465        use crate::backend::Backend;
1466
1467        let backend = AndroidBackend::init(self).await?;
1468        self.scaffold_ffi_companion().await?;
1469        self.manifest.backends.set_android(backend);
1470        self.manifest
1471            .save(&self.root)
1472            .await
1473            .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1474        Ok(())
1475    }
1476
1477    /// Initialize the GTK4 backend for an existing project.
1478    ///
1479    /// Creates necessary files/folders for the GTK4 backend under `backend_path::<Gtk4Backend>()`.
1480    ///
1481    /// # Errors
1482    /// Returns an error if scaffolding fails.
1483    pub async fn init_gtk4_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1484        use crate::{backend::Backend, gtk4::backend::Gtk4Backend};
1485
1486        self.require_distributable_backend(TargetBackend::Gtk4)
1487            .await?;
1488        if !cfg!(target_os = "linux") {
1489            return Err(crate::backend::FailToInitBackend::Io(
1490                std::io::Error::other("GTK4 backend is only supported on Linux hosts"),
1491            ));
1492        }
1493
1494        let backend = Gtk4Backend::init(self).await?;
1495        self.manifest.backends.set_gtk4(backend);
1496        self.manifest
1497            .save(&self.root)
1498            .await
1499            .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1500        Ok(())
1501    }
1502
1503    /// Initialize the hydrolysis backend for an existing project.
1504    ///
1505    /// Creates necessary files/folders for the hydrolysis backend under
1506    /// `backend_path::<HydrolysisBackend>()`.
1507    ///
1508    /// # Errors
1509    /// Returns an error if scaffolding fails.
1510    pub async fn init_hydrolysis_backend(
1511        &mut self,
1512    ) -> Result<(), crate::backend::FailToInitBackend> {
1513        use crate::{backend::Backend, hydrolysis::backend::HydrolysisBackend};
1514
1515        self.require_distributable_backend(TargetBackend::Hydrolysis)
1516            .await?;
1517        let backend = HydrolysisBackend::init(self).await?;
1518        self.manifest.backends.set_hydrolysis(backend);
1519        self.manifest
1520            .save(&self.root)
1521            .await
1522            .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1523
1524        // The Hydrolysis backend is what `water mcp` drives, so adding it is
1525        // what makes the project MCP-servable; the file is user-owned and
1526        // only written when absent.
1527        crate::mcp::ensure_mcp_json(&self.root).await?;
1528        Ok(())
1529    }
1530
1531    /// Initialize the `WinUI` backend for an existing project.
1532    ///
1533    /// Creates necessary files/folders for the `WinUI` backend under `backend_path::<WinUiBackend>()`.
1534    ///
1535    /// # Errors
1536    /// Returns an error if scaffolding fails.
1537    pub async fn init_winui_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1538        use crate::{backend::Backend, winui::backend::WinUiBackend};
1539
1540        self.require_distributable_backend(TargetBackend::WinUi)
1541            .await?;
1542        if !cfg!(target_os = "windows") {
1543            return Err(crate::backend::FailToInitBackend::Io(
1544                std::io::Error::other("WinUI backend is only supported on Windows hosts"),
1545            ));
1546        }
1547
1548        let backend = WinUiBackend::init(self).await?;
1549        self.manifest.backends.set_winui(backend);
1550        self.manifest
1551            .save(&self.root)
1552            .await
1553            .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1554        Ok(())
1555    }
1556
1557    /// Initialize the ESP32 backend for an existing project.
1558    ///
1559    /// Creates necessary files/folders for the ESP32 firmware harness under
1560    /// `backend_path::<Esp32Backend>()`.
1561    ///
1562    /// # Errors
1563    /// Returns an error if scaffolding fails.
1564    pub async fn init_esp32_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1565        use crate::{backend::Backend, esp32::backend::Esp32Backend};
1566
1567        self.require_distributable_backend(TargetBackend::Dew)
1568            .await?;
1569        let backend = Esp32Backend::init(self).await?;
1570        self.manifest.backends.set_esp32(backend);
1571        self.manifest
1572            .save(&self.root)
1573            .await
1574            .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1575        Ok(())
1576    }
1577
1578    /// Select the ESP32 target chip, persisting it to `Water.toml`.
1579    ///
1580    /// The chip is the single source of truth for the ESP32 backend's target
1581    /// triple, QEMU model, and firmware parameters. Selecting a platform such
1582    /// as `esp32c3` calls this so the generated harness and build target follow
1583    /// the platform. No-ops (and skips the manifest write) when the configured
1584    /// chip already matches.
1585    ///
1586    /// # Errors
1587    /// Returns an error if saving the manifest fails.
1588    pub async fn set_esp32_chip(
1589        &mut self,
1590        chip: crate::esp32::chip::Esp32Chip,
1591    ) -> eyre::Result<()> {
1592        let current = self.esp32_backend().cloned().unwrap_or_default();
1593        if current.chip() == chip.id() {
1594            return Ok(());
1595        }
1596        self.manifest.backends.set_esp32(current.with_chip(chip));
1597        self.save_manifest().await
1598    }
1599
1600    /// Remove Apple backend configuration and generated files.
1601    ///
1602    /// # Errors
1603    /// Returns an error if deleting files or saving manifest fails.
1604    pub async fn remove_apple_backend(&mut self) -> eyre::Result<()> {
1605        if let Some(backend) = self.apple_backend() {
1606            let path = backend.project_path().to_path_buf();
1607            self.remove_backend_relative_dir(&path).await?;
1608        }
1609        self.manifest.backends.clear_apple();
1610        self.remove_ffi_companion_if_unused().await?;
1611        self.save_manifest().await
1612    }
1613
1614    /// Remove Android backend configuration and generated files.
1615    ///
1616    /// # Errors
1617    /// Returns an error if deleting files or saving manifest fails.
1618    pub async fn remove_android_backend(&mut self) -> eyre::Result<()> {
1619        if let Some(backend) = self.android_backend() {
1620            let path = backend.project_path().clone();
1621            self.remove_backend_relative_dir(&path).await?;
1622        }
1623        self.manifest.backends.clear_android();
1624        self.remove_ffi_companion_if_unused().await?;
1625        self.save_manifest().await
1626    }
1627
1628    /// Remove GTK4 backend configuration and generated files.
1629    ///
1630    /// # Errors
1631    /// Returns an error if deleting files or saving manifest fails.
1632    pub async fn remove_gtk4_backend(&mut self) -> eyre::Result<()> {
1633        if let Some(backend) = self.gtk4_backend() {
1634            let path = backend.project_path().clone();
1635            self.remove_backend_relative_dir(&path).await?;
1636        }
1637        self.manifest.backends.clear_gtk4();
1638        self.save_manifest().await
1639    }
1640
1641    /// Remove `WinUI` backend configuration and generated files.
1642    ///
1643    /// # Errors
1644    /// Returns an error if deleting files or saving manifest fails.
1645    pub async fn remove_winui_backend(&mut self) -> eyre::Result<()> {
1646        if let Some(backend) = self.winui_backend() {
1647            let path = backend.project_path().clone();
1648            self.remove_backend_relative_dir(&path).await?;
1649        }
1650        self.manifest.backends.clear_winui();
1651        self.save_manifest().await
1652    }
1653
1654    /// Remove hydrolysis backend configuration and generated files.
1655    ///
1656    /// # Errors
1657    /// Returns an error if deleting files or saving manifest fails.
1658    pub async fn remove_hydrolysis_backend(&mut self) -> eyre::Result<()> {
1659        if let Some(backend) = self.hydrolysis_backend() {
1660            let path = backend.project_path().clone();
1661            self.remove_backend_relative_dir(&path).await?;
1662        }
1663        self.manifest.backends.clear_hydrolysis();
1664        self.save_manifest().await
1665    }
1666
1667    /// Remove ESP32 backend configuration and generated files.
1668    ///
1669    /// # Errors
1670    /// Returns an error if deleting files or saving manifest fails.
1671    pub async fn remove_esp32_backend(&mut self) -> eyre::Result<()> {
1672        if let Some(backend) = self.esp32_backend() {
1673            let path = backend.project_path().clone();
1674            self.remove_backend_relative_dir(&path).await?;
1675        }
1676        self.manifest.backends.clear_esp32();
1677        self.save_manifest().await
1678    }
1679
1680    /// Open a `WaterUI` project located at the specified path.
1681    ///
1682    /// This loads both the `Water.toml` manifest and the `Cargo.toml` file.
1683    /// For playground projects, backends are automatically initialized if not configured.
1684    ///
1685    /// # Errors
1686    /// - `FailToOpenProject::Manifest`: If there was an error opening the `Water.toml` manifest.
1687    /// - `FailToOpenProject::CargoManifest`: If there was an error reading the `Cargo.toml` file.
1688    /// - `FailToOpenProject::MissingCrateName`: If the crate name is missing in `Cargo.toml`.
1689    pub async fn open(path: impl AsRef<Path>) -> Result<Self, FailToOpenProject> {
1690        Self::open_with_mode(path, OpenMode::Full).await
1691    }
1692
1693    /// Open a project for preview dylib builds without initializing native app backends.
1694    ///
1695    /// Playground preview dylib builds only need the managed preview wrapper crate. Native
1696    /// backend initialization is reserved for support app projects that actually launch apps.
1697    ///
1698    /// # Errors
1699    /// - `FailToOpenProject::Manifest`: If there was an error opening the `Water.toml` manifest.
1700    /// - `FailToOpenProject::CargoManifest`: If there was an error reading the `Cargo.toml` file.
1701    /// - `FailToOpenProject::MissingCrateName`: If the crate name is missing in `Cargo.toml`.
1702    pub async fn open_for_preview_build(path: impl AsRef<Path>) -> Result<Self, FailToOpenProject> {
1703        Self::open_with_mode(path, OpenMode::PreviewBuild).await
1704    }
1705
1706    /// Make a local-checkout project's `[patch]` tables the checkout's.
1707    ///
1708    /// Cargo applies `[patch]` only from the workspace it builds, so a project
1709    /// on a `waterui_path` carries a copy of the checkout's tables, and the
1710    /// copy has to follow the checkout: a fork pin moves, an entry is added or
1711    /// dropped, and a project scaffolded earlier would otherwise build a graph
1712    /// the checkout no longer produces, silently. The manifest is rewritten
1713    /// only when the tables differ, so an up-to-date project stays untouched.
1714    ///
1715    /// A project that is itself a member of the checkout's workspace — every
1716    /// example and playground in this repository — needs no copy, because the
1717    /// tables Cargo reads are the checkout's own. Writing one anyway put a
1718    /// `[patch.crates-io]` table into a member manifest, where Cargo ignores it
1719    /// and says so on every single build.
1720    async fn refresh_local_patches(project_root: &Path, waterui_path: &Path) -> eyre::Result<()> {
1721        let project_root = project_root.to_path_buf();
1722        let waterui_path = waterui_path.to_path_buf();
1723        unblock(move || {
1724            let checkout = project_root.join(&waterui_path);
1725            let patch_root = templates::patch_manifest_dir(&project_root)?;
1726            if same_directory(&patch_root, &checkout)? {
1727                return Ok(());
1728            }
1729            if !same_directory(&patch_root, &project_root)? {
1730                // Cargo reads `[patch]` from `patch_root` and nothing this
1731                // function writes into the project could change that, so the
1732                // honest move is to say which manifest the tables belong in
1733                // rather than write a copy that is read by nobody.
1734                eyre::bail!(
1735                    "This project is a member of the Cargo workspace at {}, so Cargo reads \
1736                     [patch] from {} and ignores any copy here. Move the WaterUI checkout's \
1737                     [patch] tables — the ones in {} — into that workspace manifest, or take \
1738                     the project out of that workspace.",
1739                    patch_root.display(),
1740                    patch_root.join("Cargo.toml").display(),
1741                    checkout.join("Cargo.toml").display(),
1742                );
1743            }
1744            let cargo_path = project_root.join("Cargo.toml");
1745            let text = std::fs::read_to_string(&cargo_path)?;
1746            let current = CargoManifest::from_slice(text.as_bytes())?.patch;
1747            let next = templates::local_framework_patches(&project_root, &waterui_path)?;
1748            if current == next {
1749                return Ok(());
1750            }
1751            let mut document: toml_edit::DocumentMut = text.parse()?;
1752            crate::framework::rewrite_patch_tables(&mut document, &current, &next)?;
1753            std::fs::write(&cargo_path, document.to_string())?;
1754            info!(
1755                path = %cargo_path.display(),
1756                "Refreshed the [patch] tables from the local checkout"
1757            );
1758            Ok(())
1759        })
1760        .await
1761    }
1762
1763    #[allow(clippy::too_many_lines)]
1764    async fn open_with_mode(
1765        path: impl AsRef<Path>,
1766        open_mode: OpenMode,
1767    ) -> Result<Self, FailToOpenProject> {
1768        use crate::backend::Backend;
1769
1770        let total_start = std::time::Instant::now();
1771        let path = path.as_ref().to_path_buf();
1772
1773        let manifest_start = std::time::Instant::now();
1774        let manifest = Manifest::open(path.join("Water.toml"))
1775            .await
1776            .map_err(FailToOpenProject::Manifest)?;
1777        if let Some(framework) = &manifest.framework {
1778            framework
1779                .validate_cli()
1780                .map_err(FailToOpenProject::Framework)?;
1781        }
1782        if let Some(local) = &manifest.waterui_path {
1783            validate_local_cli(&path.join(local))
1784                .await
1785                .map_err(FailToOpenProject::Framework)?;
1786            Self::refresh_local_patches(&path, Path::new(local))
1787                .await
1788                .map_err(FailToOpenProject::LocalPatches)?;
1789        }
1790        info!(
1791            path = %path.display(),
1792            open_mode = ?open_mode,
1793            elapsed_ms = manifest_start.elapsed().as_millis(),
1794            "Project::open loaded Water.toml"
1795        );
1796
1797        let cargo_path = path.join("Cargo.toml");
1798
1799        let cargo_manifest_start = std::time::Instant::now();
1800        let cargo_manifest = unblock(move || CargoManifest::from_path(cargo_path))
1801            .await
1802            .map_err(FailToOpenProject::CargoManifest)?;
1803        info!(
1804            path = %path.display(),
1805            open_mode = ?open_mode,
1806            elapsed_ms = cargo_manifest_start.elapsed().as_millis(),
1807            "Project::open loaded Cargo.toml"
1808        );
1809        let crate_name = cargo_manifest
1810            .package
1811            .map(|p| p.name)
1812            .ok_or(FailToOpenProject::MissingCrateName)
1813            .and_then(|value| {
1814                CrateName::try_from(value).map_err(FailToOpenProject::InvalidCrateName)
1815            })?;
1816
1817        let is_playground = manifest.package.package_type == PackageType::Playground;
1818
1819        // Check that permissions are only set for playground projects
1820        if !is_playground && !manifest.permissions.is_empty() {
1821            return Err(FailToOpenProject::PermissionsNotAllowedInNonPlayground);
1822        }
1823
1824        // Playgrounds delegate backend projects to the CLI, so backend
1825        // scaffolding configuration is rejected. Two kinds of entries are
1826        // exceptions: `[backends.esp32]`, which is device configuration
1827        // (chip, panel geometry, bundled fonts) only the app author can
1828        // supply while its harness still lives in the managed build cache,
1829        // and `backend_path`, which selects where a backend's runtime source
1830        // comes from without configuring a project.
1831        if is_playground && manifest.backends.configures_backend_projects() {
1832            return Err(FailToOpenProject::BackendsNotAllowedInPlayground);
1833        }
1834
1835        let cargo_layout = spawn_cargo_layout_resolution(
1836            &path,
1837            manifest.framework.clone(),
1838            manifest.waterui_path.is_some(),
1839        );
1840        cargo_layout
1841            .clone()
1842            .await
1843            .map_err(|error| FailToOpenProject::Framework(eyre::eyre!(error)))?;
1844
1845        let managed_backends_root = if is_playground {
1846            let build_cache_start = std::time::Instant::now();
1847            let root = crate::water_dir::ensure_project_build_cache(&path)
1848                .await
1849                .map_err(FailToOpenProject::BuildCache)?;
1850            info!(
1851                path = %path.display(),
1852                open_mode = ?open_mode,
1853                elapsed_ms = build_cache_start.elapsed().as_millis(),
1854                "Project::open ensured project build cache"
1855            );
1856            root
1857        } else {
1858            path.join(manifest.backends.path())
1859        };
1860
1861        let mut project = Self {
1862            root: path,
1863            manifest,
1864            crate_name,
1865            cargo_layout,
1866            linked_packages: Arc::new(async_lock::OnceCell::new()),
1867            enabled_features: Arc::new(async_lock::OnceCell::new()),
1868            managed_backends_root,
1869        };
1870
1871        // For playground projects, auto-initialize backends
1872        // Always re-scaffold templates on each run to pick up manifest changes (e.g., permissions)
1873        // Build cache (build/, .gradle/, DerivedData/) is preserved since scaffold only writes template files
1874        //
1875        // Skip backend initialization when:
1876        // 1. Running inside Xcode's sandboxed build script phase (WATERUI_SKIP_RUST_BUILD=1)
1877        // 2. Running inside any sandbox (sandbox-exec sets __XCODE_BUILT_PRODUCTS_DIR_PATHS or similar)
1878        // 3. Xcode is the current build tool (ACTION env var is set by Xcode)
1879        let skip_backend_init = std::env::var("WATERUI_SKIP_RUST_BUILD")
1880            .is_ok_and(|value| value == "1")
1881            || std::env::var("ACTION").is_ok() // Xcode sets this during builds
1882            || std::env::var("XCODE_PRODUCT_BUILD_VERSION").is_ok();
1883
1884        if is_playground && !skip_backend_init && open_mode == OpenMode::Full {
1885            let apple_backend_start = std::time::Instant::now();
1886            let apple_backend = AppleBackend::init(&project)
1887                .await
1888                .map_err(FailToOpenProject::BackendInit)?;
1889            info!(
1890                path = %project.root.display(),
1891                elapsed_ms = apple_backend_start.elapsed().as_millis(),
1892                "Project::open initialized Apple backend"
1893            );
1894            project.manifest.backends.set_apple(apple_backend);
1895
1896            let android_backend_start = std::time::Instant::now();
1897            let android_backend = AndroidBackend::init(&project)
1898                .await
1899                .map_err(FailToOpenProject::BackendInit)?;
1900            info!(
1901                path = %project.root.display(),
1902                elapsed_ms = android_backend_start.elapsed().as_millis(),
1903                "Project::open initialized Android backend"
1904            );
1905            project.manifest.backends.set_android(android_backend);
1906
1907            let ffi_companion_start = std::time::Instant::now();
1908            project
1909                .scaffold_ffi_companion()
1910                .await
1911                .map_err(FailToOpenProject::BackendInit)?;
1912            info!(
1913                path = %project.root.display(),
1914                elapsed_ms = ffi_companion_start.elapsed().as_millis(),
1915                "Project::open scaffolded native ffi companion"
1916            );
1917        }
1918
1919        if !is_playground
1920            && !skip_backend_init
1921            && open_mode == OpenMode::Full
1922            && (project.apple_backend().is_some() || project.android_backend().is_some())
1923        {
1924            let ffi_companion_start = std::time::Instant::now();
1925            project
1926                .scaffold_ffi_companion()
1927                .await
1928                .map_err(FailToOpenProject::BackendInit)?;
1929            info!(
1930                path = %project.root.display(),
1931                elapsed_ms = ffi_companion_start.elapsed().as_millis(),
1932                "Project::open refreshed native ffi companion"
1933            );
1934        }
1935
1936        info!(
1937            path = %project.root.display(),
1938            open_mode = ?open_mode,
1939            elapsed_ms = total_start.elapsed().as_millis(),
1940            "Project::open completed"
1941        );
1942
1943        Ok(project)
1944    }
1945}
1946
1947impl Project {
1948    async fn save_manifest(&self) -> eyre::Result<()> {
1949        self.manifest.save(&self.root).await.map_err(Into::into)
1950    }
1951
1952    async fn remove_backend_relative_dir(&self, relative_path: &Path) -> eyre::Result<()> {
1953        let backend_path = self.managed_backends_root.join(relative_path);
1954        if backend_path.exists() {
1955            smol::fs::remove_dir_all(&backend_path).await?;
1956        }
1957        Ok(())
1958    }
1959}
1960
1961async fn apply_channel_selection(
1962    root: &Path,
1963    framework: ResolvedFramework,
1964    updates: Vec<(PathBuf, Option<Vec<u8>>)>,
1965) -> eyre::Result<()> {
1966    let mut previous = BTreeMap::new();
1967    for file in updates
1968        .iter()
1969        .map(|(file, _)| file.clone())
1970        .chain([root.join("Cargo.lock")])
1971    {
1972        let contents = match smol::fs::read(&file).await {
1973            Ok(contents) => Some(contents),
1974            Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
1975            Err(error) => return Err(error.into()),
1976        };
1977        previous.insert(file, contents);
1978    }
1979    let result = async {
1980        for (file, contents) in &updates {
1981            write_channel_file(file, contents.as_deref()).await?;
1982        }
1983        resolve_cargo_layout(root, Some(framework), CargoResolution::Update).await?;
1984        Ok(())
1985    }
1986    .await;
1987    if let Err(error) = result {
1988        for (file, contents) in previous {
1989            write_channel_file(&file, contents.as_deref())
1990                .await
1991                .map_err(|restore| {
1992                    eyre::eyre!("{error}; could not restore {}: {restore}", file.display())
1993                })?;
1994        }
1995        return Err(error);
1996    }
1997    Ok(())
1998}
1999
2000async fn write_channel_file(path: &Path, contents: Option<&[u8]>) -> std::io::Result<()> {
2001    match contents {
2002        Some(contents) => smol::fs::write(path, contents).await,
2003        None => match smol::fs::remove_file(path).await {
2004            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
2005            result => result,
2006        },
2007    }
2008}
2009
2010async fn resolve_cargo_layout(
2011    current_dir: &Path,
2012    framework: Option<ResolvedFramework>,
2013    mode: CargoResolution,
2014) -> eyre::Result<CargoLayout> {
2015    let root = current_dir.to_path_buf();
2016    let metadata = unblock(move || {
2017        let mut command = cargo_metadata::MetadataCommand::new();
2018        command.current_dir(root);
2019        match mode {
2020            CargoResolution::Local => {
2021                command.no_deps();
2022            }
2023            CargoResolution::Locked => {
2024                command.other_options(vec!["--locked".to_string()]);
2025            }
2026            CargoResolution::Update => {}
2027        }
2028        command.exec()
2029    })
2030    .await?;
2031    validate_resolved_cli(&metadata)?;
2032    if let Some(framework) = framework
2033        && framework.channel() != Some(FrameworkChannel::Stable)
2034    {
2035        let lockfile = smol::fs::read(current_dir.join("Water.lock")).await?;
2036        framework.validate_dependencies(&metadata, &lockfile)?;
2037    }
2038
2039    Ok(CargoLayout {
2040        target_dir: metadata.target_directory.into_std_path_buf(),
2041        workspace_root: metadata.workspace_root.into_std_path_buf(),
2042    })
2043}
2044
2045/// Run `cargo tree` for the application package rooted at `project_root`'s
2046/// manifest, over the given edge kinds, and return the `{p}`-formatted tree.
2047///
2048/// `locked` passes `--locked` to the resolve: trees that are read-only input —
2049/// the shared pinned-framework checkout — must fail loudly on a stale
2050/// committed lockfile instead of letting cargo rewrite it in place.
2051async fn cargo_tree(project_root: &Path, edges: &str, locked: bool) -> eyre::Result<String> {
2052    // `dunce`, not `std::fs::canonicalize`: on Windows the standard one returns
2053    // an extended-length path (`\\?\D:\...`), while `cargo metadata` reports the
2054    // plain one, so comparing the two never matched and the package below was
2055    // always "omitted" (part of #152). Canonicalize before invoking metadata,
2056    // not just on the looked-up side: metadata echoes the manifest path it is
2057    // given, so under a symlinked `TMPDIR` (`/var` → `/private/var` on macOS)
2058    // a non-canonical input can never match what metadata reports.
2059    let application_manifest = dunce::canonicalize(project_root.join("Cargo.toml"))?;
2060    let metadata_manifest = application_manifest.clone();
2061    let metadata = unblock(move || {
2062        let mut command = cargo_metadata::MetadataCommand::new();
2063        command.no_deps().manifest_path(metadata_manifest);
2064        if locked {
2065            command.other_options(vec!["--locked".to_string()]);
2066        }
2067        command.exec()
2068    })
2069    .await?;
2070    let root = metadata
2071        .packages
2072        .iter()
2073        .find(|package| package.manifest_path.as_std_path() == application_manifest)
2074        .ok_or_else(|| {
2075            eyre::eyre!(
2076                "Cargo metadata omitted the application package at {}",
2077                application_manifest.display()
2078            )
2079        })?;
2080    let package_spec = root.id.to_string();
2081    let mut tree = Command::new("cargo");
2082    tree.arg("tree")
2083        .arg("--manifest-path")
2084        .arg(&application_manifest)
2085        .arg("--package")
2086        .arg(package_spec)
2087        .arg("--edges")
2088        .arg(edges)
2089        .arg("--prefix")
2090        .arg("none")
2091        .arg("--format")
2092        .arg("{p}")
2093        .current_dir(project_root);
2094    if locked {
2095        tree.arg("--locked");
2096    }
2097    let output = tree.output().await?;
2098    if !output.status.success() {
2099        return Err(eyre::eyre!(
2100            "failed to resolve runtime dependency graph for {}: {}",
2101            application_manifest.display(),
2102            String::from_utf8_lossy(&output.stderr).trim()
2103        ));
2104    }
2105
2106    String::from_utf8(output.stdout)
2107        .map_err(|error| eyre::eyre!("Cargo runtime dependency graph is not UTF-8: {error}"))
2108}
2109
2110async fn resolve_linked_runtime_packages(
2111    project_root: PathBuf,
2112    locked: bool,
2113) -> eyre::Result<BTreeMap<String, String>> {
2114    let tree = cargo_tree(&project_root, "normal", locked).await?;
2115    let mut linked = BTreeMap::new();
2116    for package in tree.lines() {
2117        let name = package
2118            .split_ascii_whitespace()
2119            .next()
2120            .ok_or_else(|| eyre::eyre!("Cargo emitted an empty runtime dependency entry"))?;
2121        linked.insert(name.to_string(), package.to_string());
2122    }
2123
2124    Ok(linked)
2125}
2126
2127/// Feature names turned on inside the application's subtree. With `--edges
2128/// features`, `cargo tree` reports each enabled feature as a
2129/// `<package> feature "<name>"` node; only the names are kept, since the
2130/// question asked of this set is always "is a feature named X enabled".
2131async fn resolve_enabled_features(
2132    project_root: PathBuf,
2133    locked: bool,
2134) -> eyre::Result<BTreeSet<String>> {
2135    let tree = cargo_tree(&project_root, "features", locked).await?;
2136    let mut features = BTreeSet::new();
2137    for node in tree.lines() {
2138        if let Some(feature) = node
2139            .split_once(" feature \"")
2140            .and_then(|(_, rest)| rest.strip_suffix('"'))
2141        {
2142            features.insert(feature.to_string());
2143        }
2144    }
2145    Ok(features)
2146}
2147
2148use std::{
2149    collections::{BTreeMap, BTreeSet},
2150    path::{Path, PathBuf},
2151    sync::Arc,
2152};
2153
2154use serde::{Deserialize, Serialize};
2155use smol::{fs::read_to_string, process::Command, unblock};
2156use waterui_assets_planner::{LaunchConfig, ThemeConfig};
2157
2158use crate::{
2159    android::{backend::AndroidBackend, device::AndroidAbiProvider, platform::AndroidPlatform},
2160    apple::backend::AppleBackend,
2161    backend::{Backend, Backends},
2162    build::{BuildOptions, BuildProfile},
2163    device::{Artifact, Device, FailToRun, RunOptions, Running},
2164    platform::{PackageOptions, TargetBackend, TargetPlatform},
2165    project_types::{BundleIdentifier, CrateName, PermissionKey, generated_crate_name},
2166    templates::{self, TemplateContext},
2167    utils::command,
2168    web,
2169};
2170
2171/// Configuration for a `WaterUI` project persisted to `Water.toml`.
2172#[derive(Debug, Serialize, Deserialize, Clone)]
2173pub struct Manifest {
2174    /// Package information.
2175    pub package: Package,
2176    /// Backend configurations for various platforms.
2177    #[serde(default, skip_serializing_if = "Backends::is_empty")]
2178    pub backends: Backends,
2179    /// Web engine selected for the standard `WebView` component.
2180    /// Path to local `WaterUI` repository for dev mode.
2181    /// When set, all backends will use this path instead of the published versions.
2182    #[serde(skip_serializing_if = "Option::is_none")]
2183    pub waterui_path: Option<String>,
2184    /// Exact framework and backend selection, resolved only by explicit version operations.
2185    #[serde(default, skip_serializing_if = "Option::is_none")]
2186    pub framework: Option<ResolvedFramework>,
2187    /// Permission configuration for playground projects.
2188    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2189    pub permissions: BTreeMap<PermissionKey, PermissionEntry>,
2190    /// App-only configuration.
2191    #[serde(default, skip_serializing_if = "Option::is_none")]
2192    pub app: Option<AppConfig>,
2193    /// Cross-platform app theme slots.
2194    #[serde(default, skip_serializing_if = "Option::is_none")]
2195    pub theme: Option<ThemeConfig>,
2196    /// The launch screen shown until the app's first frame.
2197    #[serde(default, skip_serializing_if = "Option::is_none")]
2198    pub launch: Option<LaunchConfig>,
2199    /// Web-frontend toolchain declarations (`[web]`); only the CLI reads this.
2200    #[serde(default, skip_serializing_if = "Option::is_none")]
2201    pub web: Option<web::WebConfig>,
2202}
2203
2204/// Permission entry for playground projects.
2205#[derive(Debug, Serialize, Deserialize, Clone)]
2206pub struct PermissionEntry {
2207    enable: bool,
2208    /// Explain why this permission is needed.
2209    description: String,
2210}
2211
2212impl PermissionEntry {
2213    /// Create an enabled permission entry with the given rationale.
2214    #[must_use]
2215    pub fn enabled(description: impl Into<String>) -> Self {
2216        Self {
2217            enable: true,
2218            description: description.into(),
2219        }
2220    }
2221
2222    /// Check if this permission is enabled.
2223    #[must_use]
2224    pub const fn is_enabled(&self) -> bool {
2225        self.enable
2226    }
2227
2228    /// Get the description of why this permission is needed.
2229    #[must_use]
2230    pub fn description(&self) -> &str {
2231        &self.description
2232    }
2233}
2234
2235/// Errors that can occur when opening a `Water.toml` manifest file.
2236#[derive(Debug, thiserror::Error)]
2237pub enum FailToOpenManifest {
2238    /// Failed to read the manifest file from the filesystem.
2239    #[error("Failed to read manifest file: {0}")]
2240    ReadError(std::io::Error),
2241    /// The manifest file is invalid or malformed.
2242    #[error("Invalid manifest file: {0}")]
2243    InvalidManifest(toml::de::Error),
2244
2245    /// The manifest file was not found at the specified path.
2246    #[error("Manifest file not found at the specified path")]
2247    NotFound,
2248}
2249
2250/// Errors that can occur when saving a `Water.toml` manifest file.
2251#[derive(Debug, thiserror::Error)]
2252pub enum FailToSaveManifest {
2253    /// Failed to serialize the manifest to TOML.
2254    #[error("Failed to serialize manifest: {0}")]
2255    Serialize(toml::ser::Error),
2256    /// Failed to write the manifest file to disk.
2257    #[error("Failed to write manifest file: {0}")]
2258    Write(std::io::Error),
2259}
2260impl Manifest {
2261    /// Open and parse a `Water.toml` manifest file from the specified path.
2262    ///
2263    /// # Errors
2264    /// - `FailToOpenManifest::ReadError`: If there was an error reading the file.
2265    /// - `FailToOpenManifest::InvalidManifest`: If the file contents are not valid TOML.
2266    /// - `FailToOpenManifest::NotFound`: If the file does not exist at the specified path.
2267    pub async fn open(path: impl AsRef<Path>) -> Result<Self, FailToOpenManifest> {
2268        let path = path.as_ref();
2269        let result = read_to_string(path).await;
2270
2271        match result {
2272            Ok(c) => toml::from_str(&c).map_err(FailToOpenManifest::InvalidManifest),
2273            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(FailToOpenManifest::NotFound),
2274            Err(e) => Err(FailToOpenManifest::ReadError(e)),
2275        }
2276    }
2277
2278    /// Save the manifest to a `Water.toml` file at the specified directory.
2279    ///
2280    /// # Errors
2281    /// - If there was an error serializing the manifest to TOML.
2282    /// - If there was an error writing the file.
2283    pub async fn save(&self, dir: impl AsRef<Path>) -> Result<(), FailToSaveManifest> {
2284        let path = dir.as_ref().join("Water.toml");
2285        let content = toml::to_string_pretty(self).map_err(FailToSaveManifest::Serialize)?;
2286        smol::fs::write(&path, content)
2287            .await
2288            .map_err(FailToSaveManifest::Write)
2289    }
2290
2291    /// Create a new `Manifest` with the specified package information.
2292    #[must_use]
2293    pub fn new(package: Package) -> Self {
2294        Self {
2295            package,
2296            backends: Backends::default(),
2297            waterui_path: None,
2298            framework: None,
2299            permissions: BTreeMap::default(),
2300            app: None,
2301            theme: None,
2302            launch: None,
2303            web: None,
2304        }
2305    }
2306}
2307
2308/// The engine that draws this application's standard `WebView`.
2309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2310pub enum ResolvedWebViewBackend {
2311    /// Platform-provided `WebView`.
2312    System,
2313    /// Bundled WPE `WebKit` runtime.
2314    Wpe,
2315    /// Bundled Chromium Embedded Framework runtime.
2316    Cef,
2317}
2318
2319/// Browser engines that must be staged for one resolved application graph.
2320#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2321pub struct BrowserRuntimePlan {
2322    /// Standard `WebView` engine, when `waterui-webview` is linked.
2323    pub webview: Option<ResolvedWebViewBackend>,
2324    /// Whether the independent full Chromium component is linked.
2325    pub chromium: bool,
2326}
2327
2328impl BrowserRuntimePlan {
2329    /// Returns whether this application requires a packaged CEF runtime and
2330    /// subprocess helper.
2331    #[must_use]
2332    pub const fn requires_cef(self) -> bool {
2333        self.chromium || matches!(self.webview, Some(ResolvedWebViewBackend::Cef))
2334    }
2335}
2336
2337impl ResolvedWebViewBackend {
2338    /// Return whether this engine can be hosted by a platform and backend pair.
2339    #[must_use]
2340    pub const fn supports(self, platform: TargetPlatform, backend: TargetBackend) -> bool {
2341        match self {
2342            Self::System => matches!(
2343                (platform, backend),
2344                (
2345                    TargetPlatform::MacOS,
2346                    TargetBackend::Apple | TargetBackend::Hydrolysis
2347                ) | (
2348                    TargetPlatform::IOS
2349                        | TargetPlatform::IOSSimulator
2350                        | TargetPlatform::VisionOS
2351                        | TargetPlatform::VisionOSSimulator,
2352                    TargetBackend::Apple
2353                ) | (TargetPlatform::Android, TargetBackend::Android)
2354                    | (TargetPlatform::Linux, TargetBackend::Gtk4)
2355                    | (TargetPlatform::Web, TargetBackend::Hydrolysis)
2356            ),
2357            Self::Wpe => {
2358                matches!(platform, TargetPlatform::Linux)
2359                    && matches!(backend, TargetBackend::Gtk4 | TargetBackend::Hydrolysis)
2360            }
2361            Self::Cef => cef_is_supported(platform, backend),
2362        }
2363    }
2364
2365    /// Returns this engine, or an error naming what cannot host it.
2366    ///
2367    /// # Errors
2368    ///
2369    /// Returns an error when this platform and backend pair cannot host the
2370    /// engine the application selected.
2371    pub const fn validate(
2372        self,
2373        platform: TargetPlatform,
2374        backend: TargetBackend,
2375    ) -> Result<Self, UnsupportedWebViewBackend> {
2376        if self.supports(platform, backend) {
2377            Ok(self)
2378        } else {
2379            Err(UnsupportedWebViewBackend {
2380                resolved: self,
2381                platform,
2382                backend,
2383            })
2384        }
2385    }
2386
2387    /// Stable lowercase name used for Cargo features, runtime manifests, and diagnostics.
2388    #[must_use]
2389    pub const fn as_str(self) -> &'static str {
2390        match self {
2391            Self::System => "system",
2392            Self::Wpe => "wpe",
2393            Self::Cef => "cef",
2394        }
2395    }
2396}
2397
2398const fn cef_is_supported(platform: TargetPlatform, backend: TargetBackend) -> bool {
2399    !matches!(backend, TargetBackend::Dew)
2400        && matches!(
2401            platform,
2402            TargetPlatform::MacOS | TargetPlatform::Linux | TargetPlatform::Windows
2403        )
2404}
2405
2406/// Error returned for an unsupported `WebView` engine/platform/backend combination.
2407#[derive(Debug, thiserror::Error)]
2408#[error(
2409    "this application's WebView engine resolves to {resolved:?}, which is unsupported for \
2410     platform {platform:?} with backend {backend:?}. The engine follows the application's \
2411     dependencies: link waterui-browser-cef or waterui-browser-wpe to select one, or \
2412     neither to use the engine this platform bridges."
2413)]
2414pub struct UnsupportedWebViewBackend {
2415    resolved: ResolvedWebViewBackend,
2416    platform: TargetPlatform,
2417    backend: TargetBackend,
2418}
2419
2420/// App-specific configuration in `Water.toml`.
2421#[derive(Debug, Serialize, Deserialize, Clone, Default)]
2422pub struct AppConfig {
2423    /// Optional crate name overrides.
2424    #[serde(default, skip_serializing_if = "Option::is_none")]
2425    pub crates: Option<AppCrates>,
2426}
2427
2428/// Crate name overrides for app mode.
2429#[derive(Debug, Serialize, Deserialize, Clone, Default)]
2430pub struct AppCrates {
2431    /// Optional override crate name for generated FFI crate.
2432    #[serde(default, skip_serializing_if = "Option::is_none")]
2433    pub ffi: Option<CrateName>,
2434    /// Optional override crate name for generated GTK backend crate.
2435    #[serde(default, skip_serializing_if = "Option::is_none")]
2436    pub gtk: Option<CrateName>,
2437    /// Optional override crate name for generated hydrolysis backend crate.
2438    #[serde(default, skip_serializing_if = "Option::is_none")]
2439    pub hydrolysis: Option<CrateName>,
2440    /// Optional override crate name for generated `WinUI` backend crate.
2441    #[serde(default, skip_serializing_if = "Option::is_none")]
2442    pub winui: Option<CrateName>,
2443}
2444
2445/// `[package]` section in `Water.toml`.
2446#[derive(Debug, Serialize, Deserialize, Clone)]
2447pub struct Package {
2448    /// Type of the package (e.g., "app").
2449    #[serde(rename = "type")]
2450    pub package_type: PackageType,
2451    /// Human-readable name of the application (e.g., "Water Demo").
2452    pub name: String,
2453    /// Bundle identifier for the application (e.g., "dev.waterui.waterdemo").
2454    pub bundle_identifier: BundleIdentifier,
2455    /// Path to assets directory relative to project root. Defaults to "assets".
2456    #[serde(
2457        default = "default_assets_path",
2458        skip_serializing_if = "is_default_assets_path"
2459    )]
2460    pub assets_path: String,
2461    /// Whether to build as an accessory (headless) app on macOS.
2462    #[serde(default, skip_serializing_if = "is_false")]
2463    pub accessory: bool,
2464}
2465
2466/// Reads the `package.name` of a project's `Cargo.toml` — the crate name the
2467/// generated backends and preview symbols build on.
2468///
2469/// Lighter than [`Project::open`]: this only parses the manifest, so callers
2470/// that need just the crate name (the `water preview`/`water mcp` entry
2471/// points) do not pay for a full project open.
2472///
2473/// # Errors
2474/// Returns an error if `Cargo.toml` cannot be read or has no `package.name`.
2475pub async fn read_project_crate_name(project_path: &Path) -> eyre::Result<String> {
2476    let cargo_toml = project_path.join("Cargo.toml");
2477    let cargo_content = smol::fs::read_to_string(&cargo_toml).await?;
2478    let cargo: toml::Table = cargo_content.parse()?;
2479    cargo
2480        .get("package")
2481        .and_then(|p| p.get("name"))
2482        .and_then(|n| n.as_str())
2483        .map(ToString::to_string)
2484        .ok_or_else(|| eyre::eyre!("Could not find package name in Cargo.toml"))
2485}
2486
2487/// Whether two paths name the same directory on disk.
2488///
2489/// Compared after canonicalization, because the two sides come from different
2490/// places — one walked up from the project, one joined from a relative
2491/// `waterui_path` — and `examples/filter/../..` is the repository root however
2492/// it is spelled.
2493fn same_directory(left: &Path, right: &Path) -> std::io::Result<bool> {
2494    Ok(std::fs::canonicalize(left)? == std::fs::canonicalize(right)?)
2495}
2496
2497fn default_assets_path() -> String {
2498    "assets".to_string()
2499}
2500
2501fn is_default_assets_path(path: &str) -> bool {
2502    path == "assets"
2503}
2504
2505#[allow(clippy::trivially_copy_pass_by_ref)]
2506const fn is_false(value: &bool) -> bool {
2507    !*value
2508}
2509
2510/// Package type indicating what kind of project this is.
2511#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq)]
2512#[serde(rename_all = "lowercase")]
2513pub enum PackageType {
2514    /// A standalone application with platform-specific backends.
2515    #[default]
2516    App,
2517    /// A playground project for quick experimentation.
2518    /// Platform projects are created in a temporary directory.
2519    Playground,
2520}
2521
2522#[cfg(test)]
2523mod channel_tests {
2524    use super::*;
2525
2526    #[test]
2527    fn local_framework_requirement_is_checked_before_project_io() {
2528        smol::block_on(async {
2529            let directory = tempfile::tempdir().unwrap();
2530            let framework_root = directory.path().join("framework");
2531            let project_root = directory.path().join("consumer");
2532            smol::fs::create_dir(&framework_root).await.unwrap();
2533            let mut minimum: cargo_toml::SemVer = env!("CARGO_PKG_VERSION").parse().unwrap();
2534            minimum.major += 1;
2535            let mut metadata = toml::toml! {
2536                [package.metadata.waterui]
2537                minimum-cli-version = "0.1.4"
2538                android-min-api-level = 26
2539            };
2540            metadata["package"]["metadata"]["waterui"]["minimum-cli-version"] =
2541                toml::Value::String(minimum.to_string());
2542            smol::fs::write(
2543                framework_root.join("Cargo.toml"),
2544                toml::to_string(&metadata).unwrap(),
2545            )
2546            .await
2547            .unwrap();
2548            let bundle_identifier =
2549                BundleIdentifier::try_from("dev.waterui.compatibility").unwrap();
2550            let options = CreateOptions {
2551                name: "Compatibility".into(),
2552                bundle_identifier: bundle_identifier.clone(),
2553                package_type: PackageType::Playground,
2554                waterui_path: Some(framework_root),
2555                channel: None,
2556                framework_manifest: None,
2557                framework: None,
2558                author: String::new(),
2559                backends: Vec::new(),
2560                web: None,
2561            };
2562            let error = Project::create(&project_root, options)
2563                .await
2564                .unwrap_err()
2565                .to_string();
2566            assert!(error.contains(&format!("requires waterui-cli >= {minimum}")));
2567            assert!(error.contains(&format!(
2568                "cargo install waterui-cli --git {} --locked",
2569                env!("CARGO_PKG_REPOSITORY")
2570            )));
2571            assert!(!project_root.exists());
2572
2573            smol::fs::create_dir(&project_root).await.unwrap();
2574            let mut manifest = Manifest::new(Package {
2575                name: "Compatibility".into(),
2576                bundle_identifier,
2577                package_type: PackageType::Playground,
2578                assets_path: default_assets_path(),
2579                accessory: false,
2580            });
2581            manifest.waterui_path = Some("../framework".into());
2582            manifest.save(&project_root).await.unwrap();
2583            let error = Project::open_for_preview_build(&project_root)
2584                .await
2585                .unwrap_err()
2586                .to_string();
2587            assert!(error.contains(&format!("requires waterui-cli >= {minimum}")));
2588            assert!(!project_root.join("Cargo.lock").exists());
2589        });
2590    }
2591
2592    #[test]
2593    fn failed_channel_selection_preserves_project_files() {
2594        smol::block_on(async {
2595            let directory = tempfile::tempdir().unwrap();
2596            let root = directory.path();
2597            let originals = [
2598                ("Cargo.toml", b"original manifest".as_slice()),
2599                ("Cargo.lock", b"original dependency lock".as_slice()),
2600                ("Water.toml", b"original project configuration".as_slice()),
2601            ];
2602            for (name, contents) in originals {
2603                smol::fs::write(root.join(name), contents).await.unwrap();
2604            }
2605            let updates = ["Cargo.toml", "Cargo.lock", "Water.toml", "Water.lock"]
2606                .into_iter()
2607                .map(|name| (root.join(name), Some(b"invalid selected manifest".to_vec())))
2608                .collect();
2609            assert!(
2610                apply_channel_selection(
2611                    root,
2612                    crate::framework::test_fixtures::stable_framework(),
2613                    updates
2614                )
2615                .await
2616                .is_err()
2617            );
2618            for (name, contents) in originals {
2619                assert_eq!(smol::fs::read(root.join(name)).await.unwrap(), contents);
2620            }
2621            assert!(!root.join("Water.lock").exists());
2622        });
2623    }
2624}
2625
2626#[cfg(test)]
2627mod webview_backend_tests {
2628    use super::{
2629        ResolvedWebViewBackend, TargetBackend, TargetPlatform, resolve_enabled_features,
2630        resolve_linked_runtime_packages,
2631    };
2632
2633    /// An application that links no engine crate uses whatever the platform
2634    /// bridges, and the bridge is not everywhere: Linux outside GTK has none, so
2635    /// such a build is refused with an explanation instead of producing a
2636    /// contentless web view at runtime.
2637    #[test]
2638    fn the_platform_bridge_is_the_selection_without_an_engine_crate() {
2639        assert_eq!(
2640            ResolvedWebViewBackend::System
2641                .validate(TargetPlatform::MacOS, TargetBackend::Hydrolysis)
2642                .expect("macOS Hydrolysis bridges WKWebView"),
2643            ResolvedWebViewBackend::System
2644        );
2645        assert_eq!(
2646            ResolvedWebViewBackend::System
2647                .validate(TargetPlatform::Linux, TargetBackend::Gtk4)
2648                .expect("GTK bridges WebKitGTK"),
2649            ResolvedWebViewBackend::System
2650        );
2651        assert!(
2652            ResolvedWebViewBackend::System
2653                .validate(TargetPlatform::Linux, TargetBackend::Hydrolysis)
2654                .is_err()
2655        );
2656        assert!(
2657            ResolvedWebViewBackend::System
2658                .validate(TargetPlatform::Windows, TargetBackend::Hydrolysis)
2659                .is_err()
2660        );
2661    }
2662
2663    #[test]
2664    fn unsupported_engine_combinations_fail_before_build() {
2665        assert!(
2666            ResolvedWebViewBackend::Wpe
2667                .validate(TargetPlatform::MacOS, TargetBackend::Hydrolysis)
2668                .is_err()
2669        );
2670        assert!(
2671            ResolvedWebViewBackend::Cef
2672                .validate(TargetPlatform::Android, TargetBackend::Android)
2673                .is_err()
2674        );
2675        assert_eq!(
2676            ResolvedWebViewBackend::Cef
2677                .validate(TargetPlatform::MacOS, TargetBackend::Apple)
2678                .expect("CEF must compose with the native Apple renderer on macOS"),
2679            ResolvedWebViewBackend::Cef
2680        );
2681    }
2682
2683    #[test]
2684    fn cef_is_available_to_every_non_dew_backend_on_desktop_platforms() {
2685        for backend in [
2686            TargetBackend::Apple,
2687            TargetBackend::Android,
2688            TargetBackend::Gtk4,
2689            TargetBackend::Hydrolysis,
2690        ] {
2691            for platform in [
2692                TargetPlatform::MacOS,
2693                TargetPlatform::Linux,
2694                TargetPlatform::Windows,
2695            ] {
2696                assert_eq!(
2697                    ResolvedWebViewBackend::Cef
2698                        .validate(platform, backend)
2699                        .expect("CEF availability must not depend on the WaterUI backend"),
2700                    ResolvedWebViewBackend::Cef
2701                );
2702            }
2703        }
2704    }
2705
2706    #[test]
2707    fn cef_rejects_dew_and_platforms_without_cef_distributions() {
2708        for platform in [
2709            TargetPlatform::MacOS,
2710            TargetPlatform::Linux,
2711            TargetPlatform::Windows,
2712        ] {
2713            assert!(
2714                ResolvedWebViewBackend::Cef
2715                    .validate(platform, TargetBackend::Dew)
2716                    .is_err()
2717            );
2718        }
2719        for (platform, backend) in [
2720            (TargetPlatform::Android, TargetBackend::Android),
2721            (TargetPlatform::IOS, TargetBackend::Apple),
2722            (TargetPlatform::Web, TargetBackend::Hydrolysis),
2723        ] {
2724            assert!(
2725                ResolvedWebViewBackend::Cef
2726                    .validate(platform, backend)
2727                    .is_err()
2728            );
2729        }
2730    }
2731
2732    /// The engine is read out of the application's own graph, so the examples
2733    /// are the test: the CEF `WebView` example links `waterui-browser-cef` and
2734    /// the shared system-`WebView` example links no engine at all. The
2735    /// examples live in the framework repository — this crate builds against a
2736    /// pinned `water-rs/waterui` revision, and the test clones it on demand.
2737    #[test]
2738    #[ignore = "clones the pinned framework revision"]
2739    fn runtime_graph_is_scoped_to_the_selected_application() {
2740        let repository = crate::pinned_framework::checkout();
2741        let chromium = smol::block_on(resolve_linked_runtime_packages(
2742            repository.join("examples/chromium"),
2743            true,
2744        ))
2745        .expect("Chromium example runtime graph must resolve");
2746        assert!(
2747            chromium.contains_key("waterui-chromium"),
2748            "Chromium example graph: {chromium:#?}"
2749        );
2750        // The Chromium example links the engine it draws through, and nothing
2751        // else: no second engine, and no `waterui` facade `webview` feature.
2752        assert!(
2753            chromium.contains_key("waterui-browser-cef"),
2754            "Chromium example graph: {chromium:#?}"
2755        );
2756        assert!(
2757            !chromium.contains_key("waterui-browser-wpe"),
2758            "Chromium example graph: {chromium:#?}"
2759        );
2760        // A Chromium-only application shows no standard `WebView`, so
2761        // `webview_enabled` must be false for it: the Apple scaffold reads this
2762        // graph to decide whether to link the `WaterUICefWebView` framework.
2763        // The `waterui-webview` package is present — `waterui-chromium` links
2764        // it for the shared asset-server types — so the signal is the `webview`
2765        // feature, which nothing in this subtree turns on.
2766        assert!(
2767            chromium.contains_key("waterui-webview"),
2768            "waterui-chromium shares the webview asset-server types: {chromium:#?}"
2769        );
2770        let chromium_features = smol::block_on(resolve_enabled_features(
2771            repository.join("examples/chromium"),
2772            true,
2773        ))
2774        .expect("Chromium example feature graph must resolve");
2775        assert!(
2776            !chromium_features.contains("webview"),
2777            "a Chromium-only application must not enable the standard WebView \
2778             component: {chromium_features:#?}"
2779        );
2780
2781        let webview = smol::block_on(resolve_linked_runtime_packages(
2782            repository.join("examples/webview"),
2783            true,
2784        ))
2785        .expect("WebView example runtime graph must resolve");
2786        assert!(
2787            webview.contains_key("waterui-webview"),
2788            "WebView example graph: {webview:#?}"
2789        );
2790        let webview_features = smol::block_on(resolve_enabled_features(
2791            repository.join("examples/webview"),
2792            true,
2793        ))
2794        .expect("WebView example feature graph must resolve");
2795        assert!(
2796            webview_features.contains("webview"),
2797            "the WebView example enables the facade `webview` feature: {webview_features:#?}"
2798        );
2799        assert!(
2800            !webview.contains_key("waterui-browser-cef"),
2801            "WebView example graph: {webview:#?}"
2802        );
2803        assert!(
2804            !webview.contains_key("waterui-chromium"),
2805            "WebView example graph: {webview:#?}"
2806        );
2807
2808        let cef_webview = smol::block_on(resolve_linked_runtime_packages(
2809            repository.join("examples/webview-cef"),
2810            true,
2811        ))
2812        .expect("CEF WebView example runtime graph must resolve");
2813        assert!(
2814            cef_webview.contains_key("waterui-browser-cef"),
2815            "CEF WebView example graph: {cef_webview:#?}"
2816        );
2817        assert!(
2818            !cef_webview.contains_key("waterui-browser-wpe"),
2819            "CEF WebView example graph: {cef_webview:#?}"
2820        );
2821    }
2822
2823    /// The `map` capability — the Apple `MapKit` bridge's `-DWATERUI_MAP`, and
2824    /// the FFI's `map` feature — is read off the application's own graph, the
2825    /// same way the browser engine is. `waterui-map` is a component crate an
2826    /// application depends on directly; no facade feature announces it any
2827    /// more, so linking it is what the capability has to see. The examples
2828    /// live in the framework repository — this crate builds against a pinned
2829    /// `water-rs/waterui` revision, and the test clones it on demand.
2830    #[test]
2831    #[ignore = "clones the pinned framework revision"]
2832    fn the_map_capability_is_read_from_the_application_graph() {
2833        let repository = crate::pinned_framework::checkout();
2834
2835        let map = smol::block_on(resolve_linked_runtime_packages(
2836            repository.join("examples/map"),
2837            true,
2838        ))
2839        .expect("map example runtime graph must resolve");
2840        assert!(
2841            map.contains_key("waterui-map"),
2842            "map example graph: {map:#?}"
2843        );
2844
2845        let webview = smol::block_on(resolve_linked_runtime_packages(
2846            repository.join("examples/webview"),
2847            true,
2848        ))
2849        .expect("WebView example runtime graph must resolve");
2850        assert!(
2851            !webview.contains_key("waterui-map"),
2852            "an application that shows no map must not carry the map stack: {webview:#?}"
2853        );
2854    }
2855}
2856
2857#[cfg(test)]
2858mod scaffold_tests {
2859    use std::path::Path;
2860
2861    use super::{BundleIdentifier, CreateOptions, PackageType, Project, TargetBackend};
2862
2863    /// The documented `assets!` workflow requires the assets root to exist: the
2864    /// planner walks it recursively, so a missing directory fails the first
2865    /// `assets!` call. `water create` must therefore produce it, tracked, and at
2866    /// exactly the path the generated `Water.toml` declares.
2867    #[test]
2868    fn create_scaffolds_the_assets_directory_declared_by_the_manifest() {
2869        let dir = tempfile::tempdir().expect("temp dir");
2870        let root = dir.path().join("water-example");
2871
2872        let project = smol::block_on(Project::create(
2873            &root,
2874            CreateOptions {
2875                name: "Water Example".to_string(),
2876                bundle_identifier: BundleIdentifier::try_from("dev.waterui.waterexample")
2877                    .expect("bundle identifier"),
2878                package_type: PackageType::Playground,
2879                waterui_path: None,
2880                channel: None,
2881                framework_manifest: None,
2882                // A channel resolution would fetch the newest release from
2883                // GitHub; a unit test resolves a fixture in place instead.
2884                framework: Some(crate::framework::test_fixtures::stable_framework()),
2885                author: "Lexo Liu".to_string(),
2886                backends: Vec::new(),
2887                web: None,
2888            },
2889        ))
2890        .expect("project creation must succeed");
2891
2892        let assets = project.assets_dir();
2893        assert!(
2894            assets.is_dir(),
2895            "the assets root {} must exist after `water create`",
2896            assets.display()
2897        );
2898        assert_eq!(
2899            assets,
2900            root.join(project.assets_path()),
2901            "the scaffolded directory must be the one the manifest declares"
2902        );
2903        assert!(
2904            assets.join("README.md").is_file(),
2905            "a tracked file keeps the assets directory present in git"
2906        );
2907    }
2908
2909    /// `stable` withholds the git-pinned experimental scaffold packages, so a
2910    /// backend whose generated crate links one — GTK4, `WinUI`, Dew — must fail
2911    /// `create` before a file lands, naming the package and the channel fix
2912    /// rather than dying partway through the backend's own scaffold.
2913    #[test]
2914    fn create_rejects_backends_whose_packages_stable_withholds() {
2915        for backend in [
2916            TargetBackend::Gtk4,
2917            TargetBackend::WinUi,
2918            TargetBackend::Dew,
2919        ] {
2920            let dir = tempfile::tempdir().expect("temp dir");
2921            let root = dir.path().join("water-example");
2922            let error = smol::block_on(Project::create(
2923                &root,
2924                CreateOptions {
2925                    name: "Water Example".to_string(),
2926                    bundle_identifier: BundleIdentifier::try_from("dev.waterui.waterexample")
2927                        .expect("bundle identifier"),
2928                    package_type: PackageType::App,
2929                    waterui_path: None,
2930                    channel: None,
2931                    framework_manifest: None,
2932                    framework: Some(crate::framework::test_fixtures::stable_framework()),
2933                    author: "Lexo Liu".to_string(),
2934                    backends: vec![backend],
2935                    web: None,
2936                },
2937            ))
2938            .expect_err("a withheld scaffold package must reject create");
2939            let error = error.to_string();
2940            for package in backend.scaffold_packages() {
2941                assert!(error.contains(package), "{error}");
2942            }
2943            assert!(error.contains("stable"), "{error}");
2944            assert!(error.contains("--channel dev"), "{error}");
2945            assert!(
2946                !root.exists(),
2947                "the rejection precedes any file write: {error}"
2948            );
2949        }
2950    }
2951
2952    /// Generated crate names carry the project-root tag that keeps a shared
2953    /// Cargo target directory unambiguous; the names packaged binaries ship
2954    /// under drop it — a checkout path must never appear in a shipped
2955    /// executable name.
2956    #[test]
2957    fn shipped_binary_names_drop_the_project_root_tag() {
2958        let dir = tempfile::tempdir().expect("temp dir");
2959        let root = dir.path().join("water-example");
2960        let project = smol::block_on(Project::create(
2961            &root,
2962            CreateOptions {
2963                name: "Water Example".to_string(),
2964                bundle_identifier: BundleIdentifier::try_from("dev.waterui.waterexample")
2965                    .expect("bundle identifier"),
2966                package_type: PackageType::Playground,
2967                waterui_path: None,
2968                channel: None,
2969                framework_manifest: None,
2970                framework: Some(crate::framework::test_fixtures::stable_framework()),
2971                author: "Lexo Liu".to_string(),
2972                backends: Vec::new(),
2973                web: None,
2974            },
2975        ))
2976        .expect("project creation must succeed");
2977
2978        for (shipped, tagged) in [
2979            (project.gtk4_binary_name(), project.gtk_backend_crate_name()),
2980            (
2981                project.hydrolysis_binary_name(),
2982                project.hydrolysis_backend_crate_name(),
2983            ),
2984            (
2985                project.winui_binary_name(),
2986                project.winui_backend_crate_name(),
2987            ),
2988            (
2989                project.esp32_binary_name(),
2990                project.esp32_backend_crate_name(),
2991            ),
2992        ] {
2993            assert!(
2994                tagged.as_str().starts_with(&format!("{shipped}-")),
2995                "the build name must be the shipped name plus the tag: {tagged}"
2996            );
2997            assert_eq!(
2998                tagged.as_str().len() - shipped.as_str().len(),
2999                9,
3000                "the tag is a dash plus eight hex digits: {tagged}"
3001            );
3002        }
3003    }
3004
3005    /// Packaged executables stage under the project's own managed backend
3006    /// directory — `dist/<platform>/<profile>` below `backend_path` — so
3007    /// two projects sharing a crate name, most often two worktrees of one
3008    /// project, never write the same shipped path the way the shared Cargo
3009    /// profile directory made them.
3010    #[test]
3011    fn same_named_projects_stage_packaged_binaries_under_their_own_backends() {
3012        let dir = tempfile::tempdir().expect("temp dir");
3013        let create = |root: &Path| {
3014            smol::block_on(Project::create(
3015                root,
3016                CreateOptions {
3017                    name: "Water Example".to_string(),
3018                    bundle_identifier: BundleIdentifier::try_from("dev.waterui.waterexample")
3019                        .expect("bundle identifier"),
3020                    package_type: PackageType::App,
3021                    waterui_path: None,
3022                    channel: None,
3023                    framework_manifest: None,
3024                    framework: Some(crate::framework::test_fixtures::stable_framework()),
3025                    author: "Lexo Liu".to_string(),
3026                    backends: Vec::new(),
3027                    web: None,
3028                },
3029            ))
3030            .expect("project creation must succeed")
3031        };
3032        let first = create(&dir.path().join("one/demo"));
3033        let second = create(&dir.path().join("two/demo"));
3034
3035        let staged = |project: &Project| {
3036            crate::platforming::packaging::dist_dir(
3037                &project.backend_path::<crate::hydrolysis::backend::HydrolysisBackend>(),
3038                "linux",
3039                Some("release"),
3040            )
3041            .join(project.hydrolysis_binary_name().as_str())
3042        };
3043        let first_staged = staged(&first);
3044        let second_staged = staged(&second);
3045
3046        assert_ne!(
3047            first_staged, second_staged,
3048            "same-named projects must not stage the same shipped path"
3049        );
3050        for (project, staged) in [(&first, &first_staged), (&second, &second_staged)] {
3051            assert!(
3052                staged.starts_with(
3053                    project.backend_path::<crate::hydrolysis::backend::HydrolysisBackend>()
3054                ),
3055                "{} must live under the project's own managed backend directory",
3056                staged.display()
3057            );
3058        }
3059    }
3060}
3061
3062#[cfg(test)]
3063mod local_patch_tests {
3064    use std::path::Path;
3065
3066    use super::Project;
3067
3068    /// A project on a `waterui_path` mirrors the checkout's `[patch]` tables
3069    /// every time it opens: entries the checkout dropped disappear, moved ones
3070    /// follow, and a project already in line is left byte-for-byte alone.
3071    #[test]
3072    fn a_local_checkout_project_follows_the_checkouts_patch_tables() {
3073        let directory = tempfile::tempdir().expect("temp dir");
3074        let checkout = directory.path().join("waterui");
3075        std::fs::create_dir_all(&checkout).expect("checkout dir");
3076        std::fs::write(
3077            checkout.join("Cargo.toml"),
3078            include_str!("../../tests/fixtures/local_checkout_patches.toml"),
3079        )
3080        .expect("checkout manifest");
3081        let app = directory.path().join("app");
3082        std::fs::create_dir_all(&app).expect("project dir");
3083        let cargo_path = app.join("Cargo.toml");
3084        std::fs::write(
3085            &cargo_path,
3086            "[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",
3087        )
3088        .expect("project manifest");
3089
3090        smol::block_on(Project::refresh_local_patches(
3091            &app,
3092            Path::new("../waterui"),
3093        ))
3094        .expect("tables refresh");
3095        let refreshed = std::fs::read_to_string(&cargo_path).expect("refreshed manifest");
3096        let manifest = cargo_toml::Manifest::from_str(&refreshed).expect("manifest parses");
3097        let crates_io = &manifest.patch["crates-io"];
3098        let cargo_toml::Dependency::Detailed(core) = &crates_io["waterui-core"] else {
3099            panic!("the core patch is a path dependency");
3100        };
3101        assert_eq!(core.path.as_deref(), Some("../waterui/core"));
3102        assert!(!crates_io.contains_key("stale"));
3103        assert!(crates_io.contains_key("vello"));
3104        assert!(refreshed.starts_with("[package]"));
3105
3106        smol::block_on(Project::refresh_local_patches(
3107            &app,
3108            Path::new("../waterui"),
3109        ))
3110        .expect("second refresh");
3111        assert_eq!(
3112            std::fs::read_to_string(&cargo_path).expect("manifest after the second refresh"),
3113            refreshed
3114        );
3115    }
3116
3117    /// A project inside the checkout's own workspace — every example in this
3118    /// repository — is already governed by the checkout's tables, so nothing is
3119    /// copied into the member manifest, where Cargo would ignore it and warn on
3120    /// every build.
3121    #[test]
3122    fn a_member_of_the_checkouts_workspace_keeps_its_manifest() {
3123        let directory = tempfile::tempdir().expect("temp dir");
3124        let checkout = directory.path().join("waterui");
3125        let app = checkout.join("examples/app");
3126        std::fs::create_dir_all(&app).expect("project dir");
3127        std::fs::write(
3128            checkout.join("Cargo.toml"),
3129            include_str!("../../tests/fixtures/local_checkout_patches.toml"),
3130        )
3131        .expect("checkout manifest");
3132        let manifest = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nwaterui = { path = \"../..\" }\n";
3133        let cargo_path = app.join("Cargo.toml");
3134        std::fs::write(&cargo_path, manifest).expect("project manifest");
3135
3136        smol::block_on(Project::refresh_local_patches(&app, Path::new("../..")))
3137            .expect("tables refresh");
3138
3139        assert_eq!(
3140            std::fs::read_to_string(&cargo_path).expect("manifest after the refresh"),
3141            manifest
3142        );
3143    }
3144
3145    /// A project inside someone else's workspace cannot carry the tables at all:
3146    /// Cargo reads them from that workspace root. Saying which manifest they
3147    /// belong in beats writing a copy that is read by nobody.
3148    #[test]
3149    fn a_member_of_a_foreign_workspace_is_told_where_the_tables_belong() {
3150        let directory = tempfile::tempdir().expect("temp dir");
3151        let checkout = directory.path().join("waterui");
3152        std::fs::create_dir_all(&checkout).expect("checkout dir");
3153        std::fs::write(
3154            checkout.join("Cargo.toml"),
3155            include_str!("../../tests/fixtures/local_checkout_patches.toml"),
3156        )
3157        .expect("checkout manifest");
3158        let workspace = directory.path().join("their-workspace");
3159        let app = workspace.join("app");
3160        std::fs::create_dir_all(&app).expect("project dir");
3161        std::fs::write(
3162            workspace.join("Cargo.toml"),
3163            "[workspace]\nmembers = [\"app\"]\n",
3164        )
3165        .expect("workspace manifest");
3166        let manifest = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nwaterui = { path = \"../../waterui\" }\n";
3167        let cargo_path = app.join("Cargo.toml");
3168        std::fs::write(&cargo_path, manifest).expect("project manifest");
3169
3170        let error = smol::block_on(Project::refresh_local_patches(
3171            &app,
3172            Path::new("../../waterui"),
3173        ))
3174        .expect_err("a copy here would be ignored");
3175
3176        let message = error.to_string();
3177        assert!(message.contains("their-workspace"), "{message}");
3178        assert_eq!(
3179            std::fs::read_to_string(&cargo_path).expect("manifest after the refusal"),
3180            manifest
3181        );
3182    }
3183}