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         (device settings under [backends.esp32] are the exception)"
971    )]
972    BackendsNotAllowedInPlayground,
973
974    /// Failed to initialize backend for playground project.
975    #[error("Failed to initialize backend: {0}")]
976    BackendInit(#[from] crate::backend::FailToInitBackend),
977
978    /// Failed to manage the global build cache directory.
979    #[error("Failed to prepare managed build cache: {0}")]
980    BuildCache(#[from] eyre::Report),
981}
982
983/// Errors that can occur when creating a new `WaterUI` project.
984#[derive(Debug, thiserror::Error)]
985pub enum FailToCreateProject {
986    /// Failed to resolve a coherent framework distribution.
987    #[error("Failed to resolve framework: {0}")]
988    Framework(eyre::Report),
989    /// The project directory already exists.
990    #[error("Directory already exists: {0}")]
991    DirectoryExists(PathBuf),
992    /// The directory is already a `WaterUI` project.
993    #[error("{0} is already a WaterUI project (Water.toml exists)")]
994    AlreadyProject(PathBuf),
995    /// The directory already contains a Cargo manifest that scaffolding
996    /// would overwrite.
997    #[error(
998        "{0} already contains a Cargo.toml; merge the generated scaffold manually or remove it first"
999    )]
1000    CargoManifestExists(PathBuf),
1001    /// Failed to create project directory.
1002    #[error("Failed to create directory: {0}")]
1003    CreateDir(std::io::Error),
1004    /// Failed to scaffold project files.
1005    #[error("Failed to scaffold project: {0}")]
1006    Scaffold(std::io::Error),
1007    /// Failed to save manifest.
1008    #[error("Failed to save manifest: {0}")]
1009    SaveManifest(#[from] FailToSaveManifest),
1010
1011    /// Failed to get Cargo metadata.
1012    #[error("Failed to get Cargo metadata: {0}")]
1013    TargetDirError(#[from] cargo_metadata::Error),
1014
1015    /// Failed to resolve the managed build cache path.
1016    #[error("Failed to resolve managed build cache: {0}")]
1017    BuildCache(#[from] eyre::Report),
1018
1019    /// Failed to initialize git repository.
1020    #[error("Failed to initialize git repository: {0}")]
1021    GitInit(std::io::Error),
1022    /// Failed to check git repository status.
1023    #[error("Failed to check git repository status: {0}")]
1024    GitStatus(std::io::Error),
1025}
1026
1027/// Options for creating a new `WaterUI` project.
1028#[derive(Debug, Clone)]
1029pub struct CreateOptions {
1030    /// Application display name (e.g., "Water Example").
1031    pub name: String,
1032    /// Bundle identifier (e.g., "dev.waterui.waterexample").
1033    pub bundle_identifier: BundleIdentifier,
1034    /// Package type for the project.
1035    pub package_type: PackageType,
1036    /// Path to local `WaterUI` repository for development.
1037    pub waterui_path: Option<PathBuf>,
1038    /// Framework channel, mutually exclusive with a local source path and a
1039    /// manifest file.
1040    pub channel: Option<FrameworkChannel>,
1041    /// A certified `framework.json` on disk, mutually exclusive with a channel
1042    /// and a local source path: the project pins the channel and revision the
1043    /// manifest declares.
1044    pub framework_manifest: Option<PathBuf>,
1045    /// An already-resolved framework selection — how a support app inherits
1046    /// the host project's framework exactly. Mutually exclusive with every
1047    /// resolving source above.
1048    pub framework: Option<ResolvedFramework>,
1049    /// Author name for Cargo.toml.
1050    pub author: String,
1051    /// The backends the caller will scaffold after creation: each one's
1052    /// scaffold packages are held against the resolved channel before a
1053    /// file is written, so a package the channel withholds — the git-pinned
1054    /// experimental set — fails the create rather than the backend init.
1055    pub backends: Vec<TargetBackend>,
1056    /// The declared web frontend: `Some` generates the `include_web!` root
1057    /// view and writes `[web] package_manager`.
1058    pub web: Option<WebScaffold>,
1059}
1060
1061/// How `create`/`init` wires a declared web frontend into the scaffold.
1062#[derive(Debug, Clone)]
1063pub struct WebScaffold {
1064    /// The package manager written to `[web] package_manager`.
1065    pub package_manager: web::PackageManager,
1066    /// The `include_web!` argument: `"web"` for the conventional layout, or a
1067    /// path relative to the project root for a frontend referenced in place.
1068    pub include_arg: String,
1069}
1070
1071impl CreateOptions {
1072    fn crate_name(&self) -> Result<CrateName, FailToCreateProject> {
1073        let name = self
1074            .name
1075            .chars()
1076            .map(|character| {
1077                if character.is_alphanumeric() {
1078                    character.to_ascii_lowercase()
1079                } else {
1080                    '_'
1081                }
1082            })
1083            .collect::<String>();
1084        CrateName::try_from(name).map_err(|error| {
1085            FailToCreateProject::Scaffold(std::io::Error::new(
1086                std::io::ErrorKind::InvalidInput,
1087                error,
1088            ))
1089        })
1090    }
1091
1092    /// The framework the scaffold resolves against — always resolved: a
1093    /// channel's certified release, a manifest file's, a caller-supplied
1094    /// selection, or the checkout `waterui_path` names. A checkout's framework
1095    /// is a filesystem source, so it is never persisted into `Water.toml`;
1096    /// `waterui_path` itself is the record.
1097    async fn resolve_framework(&mut self) -> eyre::Result<(ResolvedFramework, Option<Vec<u8>>)> {
1098        let selected = [
1099            self.waterui_path.is_some(),
1100            self.channel.is_some(),
1101            self.framework_manifest.is_some(),
1102            self.framework.is_some(),
1103        ]
1104        .into_iter()
1105        .filter(|selected| *selected)
1106        .count();
1107        if selected > 1 {
1108            eyre::bail!(
1109                "a framework channel, a local source path, a framework manifest, \
1110                 and a resolved framework are mutually exclusive"
1111            );
1112        }
1113        if let Some(path) = &self.waterui_path {
1114            // `dunce`, not `std`'s canonicalize: on Windows the standard one
1115            // returns an extended-length path (`\\?\C:\…`), and a scaffolded
1116            // manifest that carries it as a dependency `path` is one Cargo
1117            // refuses to parse ("invalid path url").
1118            let path = path.clone();
1119            let root = unblock(move || dunce::canonicalize(path)).await?;
1120            self.waterui_path = Some(root.clone());
1121            return Ok((ResolvedFramework::for_local_checkout(&root).await?, None));
1122        }
1123        if let Some(path) = &self.framework_manifest {
1124            return ResolvedFramework::resolve_manifest(path).await;
1125        }
1126        if let Some(framework) = &self.framework {
1127            return Ok((framework.clone(), None));
1128        }
1129        ResolvedFramework::resolve(self.channel.unwrap_or_default()).await
1130    }
1131}
1132
1133impl Project {
1134    async fn scaffold_ffi_companion(&self) -> Result<(), crate::backend::FailToInitBackend> {
1135        let manifest = self.manifest();
1136        let app_name = manifest
1137            .package
1138            .name
1139            .chars()
1140            .filter(|c| c.is_alphanumeric())
1141            .collect::<String>();
1142        let webview_enabled = self
1143            .uses_standard_webview()
1144            .await
1145            .map_err(crate::backend::FailToInitBackend::Config)?;
1146        let chromium_enabled = self
1147            .links_runtime_package("waterui-chromium")
1148            .await
1149            .map_err(crate::backend::FailToInitBackend::Config)?;
1150        let browser_engine = self
1151            .linked_browser_engine()
1152            .await
1153            .map_err(crate::backend::FailToInitBackend::Config)?;
1154        let framework = self
1155            .resolved_framework()
1156            .await
1157            .map_err(crate::backend::FailToInitBackend::Config)?;
1158        let ctx = TemplateContext::for_project_manifest(
1159            manifest,
1160            self.crate_name().clone(),
1161            app_name,
1162            &framework,
1163        )
1164        .with_backend_project_path(self.ffi_crate_path())
1165        .with_project_root_path(self.root.clone())
1166        .with_webview_enabled(webview_enabled)
1167        .with_chromium_enabled(chromium_enabled)
1168        .with_browser_engine(browser_engine);
1169
1170        templates::ffi::scaffold(&self.ffi_crate_path(), &ctx, &self.ffi_crate_name())
1171            .await
1172            .map_err(crate::backend::FailToInitBackend::Io)?;
1173
1174        let lockfile = self
1175            .lockfile_path()
1176            .await
1177            .map_err(crate::backend::FailToInitBackend::Config)?;
1178        templates::ffi::seed_lockfile(&self.ffi_crate_path(), &lockfile)
1179            .await
1180            .map_err(crate::backend::FailToInitBackend::Io)
1181    }
1182
1183    /// Scaffold this project's preview module inside `workspace_root`.
1184    ///
1185    /// # Errors
1186    ///
1187    /// Returns an error when the generated crate cannot be written.
1188    pub async fn scaffold_preview_ffi_companion(
1189        &self,
1190        workspace_root: &Path,
1191    ) -> Result<PathBuf, crate::backend::FailToInitBackend> {
1192        let manifest = self.manifest();
1193        let app_name = manifest
1194            .package
1195            .name
1196            .chars()
1197            .filter(|c| c.is_alphanumeric())
1198            .collect::<String>();
1199        let framework = self
1200            .resolved_framework()
1201            .await
1202            .map_err(crate::backend::FailToInitBackend::Config)?;
1203        let ctx = TemplateContext::for_project_manifest(
1204            manifest,
1205            self.crate_name().clone(),
1206            app_name,
1207            &framework,
1208        )
1209        .with_backend_project_path(self.preview_ffi_crate_path(workspace_root))
1210        .with_project_root_path(self.root.clone());
1211
1212        let crate_path = self.preview_ffi_crate_path(workspace_root);
1213        templates::preview_ffi::scaffold(&crate_path, &ctx, &self.preview_ffi_crate_name())
1214            .await
1215            .map_err(crate::backend::FailToInitBackend::Io)?;
1216        Ok(crate_path)
1217    }
1218
1219    async fn remove_ffi_companion_if_unused(&self) -> eyre::Result<()> {
1220        if self.apple_backend().is_some() || self.android_backend().is_some() {
1221            return Ok(());
1222        }
1223
1224        let ffi_path = self.ffi_crate_path();
1225        if ffi_path.exists() {
1226            smol::fs::remove_dir_all(&ffi_path).await?;
1227        }
1228
1229        Ok(())
1230    }
1231
1232    /// Create a new `WaterUI` project at the specified path.
1233    ///
1234    /// This creates the project directory, scaffolds root files (Cargo.toml, src/lib.rs),
1235    /// and saves the Water.toml manifest. Use `init_apple_backend()` and `init_android_backend()`
1236    /// to scaffold platform backends after creation.
1237    ///
1238    /// # Errors
1239    /// - `FailToCreateProject::DirectoryExists`: If the directory already exists.
1240    /// - `FailToCreateProject::CreateDir`: If creating the directory fails.
1241    /// - `FailToCreateProject::Scaffold`: If scaffolding files fails.
1242    /// - `FailToCreateProject::SaveManifest`: If saving the manifest fails.
1243    pub async fn create(
1244        path: impl AsRef<Path>,
1245        options: CreateOptions,
1246    ) -> Result<Self, FailToCreateProject> {
1247        let path = path.as_ref().to_path_buf();
1248
1249        // Check if directory already exists
1250        if path.exists() {
1251            return Err(FailToCreateProject::DirectoryExists(path));
1252        }
1253
1254        Self::scaffold_project(path, options).await
1255    }
1256
1257    /// Initialize a `WaterUI` project inside an existing directory
1258    /// (`water init`): the same scaffold as [`Project::create`] without the
1259    /// directory-creation step.
1260    ///
1261    /// # Errors
1262    /// - `FailToCreateProject::AlreadyProject`: If `Water.toml` already exists.
1263    /// - `FailToCreateProject::CargoManifestExists`: If `Cargo.toml` already
1264    ///   exists and would be overwritten.
1265    /// - the [`Project::create`] scaffold errors.
1266    pub async fn init(
1267        path: impl AsRef<Path>,
1268        options: CreateOptions,
1269    ) -> Result<Self, FailToCreateProject> {
1270        let path = path.as_ref().to_path_buf();
1271        if path.join("Water.toml").exists() {
1272            return Err(FailToCreateProject::AlreadyProject(path));
1273        }
1274        if path.join("Cargo.toml").exists() {
1275            return Err(FailToCreateProject::CargoManifestExists(path));
1276        }
1277        Self::scaffold_project(path, options).await
1278    }
1279
1280    async fn scaffold_project(
1281        path: PathBuf,
1282        mut options: CreateOptions,
1283    ) -> Result<Self, FailToCreateProject> {
1284        // Derive crate name from display name
1285        let crate_name = options.crate_name()?;
1286        let (framework, lockfile) = options
1287            .resolve_framework()
1288            .await
1289            .map_err(FailToCreateProject::Framework)?;
1290
1291        // A backend whose scaffold packages the channel withholds cannot be
1292        // scaffolded at all — reject before a single file lands.
1293        for backend in &options.backends {
1294            for package in backend.scaffold_packages() {
1295                framework
1296                    .require_distributable(package)
1297                    .map_err(FailToCreateProject::Framework)?;
1298            }
1299        }
1300
1301        // Framework validation precedes directory creation so a rejected
1302        // local checkout leaves nothing behind; on `init` the directory
1303        // already exists and this is a no-op.
1304        smol::fs::create_dir_all(&path)
1305            .await
1306            .map_err(FailToCreateProject::CreateDir)?;
1307
1308        // Build template context for root files
1309        let ctx = TemplateContext::for_create_options(&options, crate_name.clone(), &framework);
1310
1311        // The assets root is derived once and shared with both the scaffold and
1312        // the manifest, so the created directory and `Water.toml` cannot disagree.
1313        let assets_path = default_assets_path();
1314
1315        // Scaffold root files (Cargo.toml, src/lib.rs, .gitignore, assets/README.md)
1316        templates::root::scaffold(&path, &ctx, &assets_path)
1317            .await
1318            .map_err(FailToCreateProject::Scaffold)?;
1319
1320        // `.mcp.json` lets MCP clients launched in the project root find
1321        // `water mcp` without any user configuration.
1322        crate::mcp::ensure_mcp_json(&path)
1323            .await
1324            .map_err(FailToCreateProject::Scaffold)?;
1325        if let Some(lockfile) = lockfile {
1326            let contents = ctx
1327                .framework
1328                .cargo_lock(&lockfile)
1329                .map_err(FailToCreateProject::Framework)?
1330                .to_string();
1331            smol::fs::write(path.join("Water.lock"), lockfile)
1332                .await
1333                .map_err(FailToCreateProject::Scaffold)?;
1334            smol::fs::write(path.join("Cargo.lock"), contents)
1335                .await
1336                .map_err(FailToCreateProject::Scaffold)?;
1337        }
1338
1339        // Build manifest
1340        let mut backends = Backends::default();
1341        if options.package_type == PackageType::App {
1342            backends.set_path("backends");
1343        }
1344
1345        let manifest = Manifest {
1346            package: Package {
1347                package_type: options.package_type,
1348                name: options.name.clone(),
1349                bundle_identifier: options.bundle_identifier.clone(),
1350                assets_path,
1351                accessory: false,
1352            },
1353            backends,
1354            waterui_path: options
1355                .waterui_path
1356                .as_ref()
1357                .map(|p| p.display().to_string()),
1358            // A local checkout's framework is a filesystem source — never
1359            // persisted; `waterui_path` above is the record.
1360            framework: framework.channel().is_some().then_some(framework),
1361            permissions: BTreeMap::default(),
1362            app: None,
1363            theme: None,
1364            launch: None,
1365            web: options.web.as_ref().map(|scaffold| web::WebConfig {
1366                package_manager: scaffold.package_manager,
1367            }),
1368        };
1369
1370        // Save Water.toml
1371        manifest.save(&path).await?;
1372
1373        // Initialize git repository if not already in one
1374        Self::ensure_git_init(&path).await?;
1375
1376        let managed_backends_root = if options.package_type == PackageType::Playground {
1377            crate::water_dir::project_build_cache_dir(&path)
1378                .await
1379                .map_err(FailToCreateProject::BuildCache)?
1380        } else {
1381            path.join(manifest.backends.path())
1382        };
1383
1384        let cargo_layout = if let Some(framework) = &manifest.framework {
1385            let layout =
1386                resolve_cargo_layout(&path, Some(framework.clone()), CargoResolution::Update)
1387                    .await
1388                    .map_err(FailToCreateProject::Framework)?;
1389            futures_util::future::ready(Ok::<CargoLayout, String>(layout))
1390                .boxed()
1391                .shared()
1392        } else {
1393            spawn_cargo_layout_resolution(&path, None, true)
1394        };
1395        Ok(Self {
1396            root: path,
1397            manifest,
1398            crate_name,
1399            cargo_layout,
1400            linked_packages: Arc::new(async_lock::OnceCell::new()),
1401            enabled_features: Arc::new(async_lock::OnceCell::new()),
1402            managed_backends_root,
1403        })
1404    }
1405
1406    /// Ensure the project is initialized with git.
1407    ///
1408    /// Checks if the project directory is already part of a git repository.
1409    /// If not, initializes a new git repository.
1410    async fn ensure_git_init(path: &Path) -> Result<(), FailToCreateProject> {
1411        // Check if already in a git repository
1412
1413        let mut cmd = Command::new("git");
1414
1415        let is_in_git = command(&mut cmd)
1416            .args(["rev-parse", "--git-dir"])
1417            .current_dir(path)
1418            .output()
1419            .await
1420            .map_err(FailToCreateProject::GitStatus)?
1421            .status
1422            .success();
1423
1424        if !is_in_git {
1425            // Initialize a new git repository
1426            let mut cmd = Command::new("git");
1427            command(&mut cmd)
1428                .args(["init"])
1429                .current_dir(path)
1430                .status()
1431                .await
1432                .map_err(FailToCreateProject::GitInit)?;
1433        }
1434
1435        Ok(())
1436    }
1437
1438    /// Initialize the Apple backend for this project.
1439    ///
1440    /// This scaffolds the Apple backend files and updates the manifest.
1441    ///
1442    /// # Errors
1443    /// Returns an error if scaffolding fails.
1444    pub async fn init_apple_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1445        use crate::backend::Backend;
1446
1447        let backend = AppleBackend::init(self).await?;
1448        self.scaffold_ffi_companion().await?;
1449        self.manifest.backends.set_apple(backend);
1450        self.manifest
1451            .save(&self.root)
1452            .await
1453            .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1454        Ok(())
1455    }
1456
1457    /// Initialize the Android backend for this project.
1458    ///
1459    /// This scaffolds the Android backend files and updates the manifest.
1460    ///
1461    /// # Errors
1462    /// Returns an error if scaffolding fails.
1463    pub async fn init_android_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1464        use crate::backend::Backend;
1465
1466        let backend = AndroidBackend::init(self).await?;
1467        self.scaffold_ffi_companion().await?;
1468        self.manifest.backends.set_android(backend);
1469        self.manifest
1470            .save(&self.root)
1471            .await
1472            .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1473        Ok(())
1474    }
1475
1476    /// Initialize the GTK4 backend for an existing project.
1477    ///
1478    /// Creates necessary files/folders for the GTK4 backend under `backend_path::<Gtk4Backend>()`.
1479    ///
1480    /// # Errors
1481    /// Returns an error if scaffolding fails.
1482    pub async fn init_gtk4_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1483        use crate::{backend::Backend, gtk4::backend::Gtk4Backend};
1484
1485        self.require_distributable_backend(TargetBackend::Gtk4)
1486            .await?;
1487        if !cfg!(target_os = "linux") {
1488            return Err(crate::backend::FailToInitBackend::Io(
1489                std::io::Error::other("GTK4 backend is only supported on Linux hosts"),
1490            ));
1491        }
1492
1493        let backend = Gtk4Backend::init(self).await?;
1494        self.manifest.backends.set_gtk4(backend);
1495        self.manifest
1496            .save(&self.root)
1497            .await
1498            .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1499        Ok(())
1500    }
1501
1502    /// Initialize the hydrolysis backend for an existing project.
1503    ///
1504    /// Creates necessary files/folders for the hydrolysis backend under
1505    /// `backend_path::<HydrolysisBackend>()`.
1506    ///
1507    /// # Errors
1508    /// Returns an error if scaffolding fails.
1509    pub async fn init_hydrolysis_backend(
1510        &mut self,
1511    ) -> Result<(), crate::backend::FailToInitBackend> {
1512        use crate::{backend::Backend, hydrolysis::backend::HydrolysisBackend};
1513
1514        self.require_distributable_backend(TargetBackend::Hydrolysis)
1515            .await?;
1516        let backend = HydrolysisBackend::init(self).await?;
1517        self.manifest.backends.set_hydrolysis(backend);
1518        self.manifest
1519            .save(&self.root)
1520            .await
1521            .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1522
1523        // The Hydrolysis backend is what `water mcp` drives, so adding it is
1524        // what makes the project MCP-servable; the file is user-owned and
1525        // only written when absent.
1526        crate::mcp::ensure_mcp_json(&self.root).await?;
1527        Ok(())
1528    }
1529
1530    /// Initialize the `WinUI` backend for an existing project.
1531    ///
1532    /// Creates necessary files/folders for the `WinUI` backend under `backend_path::<WinUiBackend>()`.
1533    ///
1534    /// # Errors
1535    /// Returns an error if scaffolding fails.
1536    pub async fn init_winui_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1537        use crate::{backend::Backend, winui::backend::WinUiBackend};
1538
1539        self.require_distributable_backend(TargetBackend::WinUi)
1540            .await?;
1541        if !cfg!(target_os = "windows") {
1542            return Err(crate::backend::FailToInitBackend::Io(
1543                std::io::Error::other("WinUI backend is only supported on Windows hosts"),
1544            ));
1545        }
1546
1547        let backend = WinUiBackend::init(self).await?;
1548        self.manifest.backends.set_winui(backend);
1549        self.manifest
1550            .save(&self.root)
1551            .await
1552            .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1553        Ok(())
1554    }
1555
1556    /// Initialize the ESP32 backend for an existing project.
1557    ///
1558    /// Creates necessary files/folders for the ESP32 firmware harness under
1559    /// `backend_path::<Esp32Backend>()`.
1560    ///
1561    /// # Errors
1562    /// Returns an error if scaffolding fails.
1563    pub async fn init_esp32_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
1564        use crate::{backend::Backend, esp32::backend::Esp32Backend};
1565
1566        self.require_distributable_backend(TargetBackend::Dew)
1567            .await?;
1568        let backend = Esp32Backend::init(self).await?;
1569        self.manifest.backends.set_esp32(backend);
1570        self.manifest
1571            .save(&self.root)
1572            .await
1573            .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
1574        Ok(())
1575    }
1576
1577    /// Select the ESP32 target chip, persisting it to `Water.toml`.
1578    ///
1579    /// The chip is the single source of truth for the ESP32 backend's target
1580    /// triple, QEMU model, and firmware parameters. Selecting a platform such
1581    /// as `esp32c3` calls this so the generated harness and build target follow
1582    /// the platform. No-ops (and skips the manifest write) when the configured
1583    /// chip already matches.
1584    ///
1585    /// # Errors
1586    /// Returns an error if saving the manifest fails.
1587    pub async fn set_esp32_chip(
1588        &mut self,
1589        chip: crate::esp32::chip::Esp32Chip,
1590    ) -> eyre::Result<()> {
1591        let current = self.esp32_backend().cloned().unwrap_or_default();
1592        if current.chip() == chip.id() {
1593            return Ok(());
1594        }
1595        self.manifest.backends.set_esp32(current.with_chip(chip));
1596        self.save_manifest().await
1597    }
1598
1599    /// Remove Apple backend configuration and generated files.
1600    ///
1601    /// # Errors
1602    /// Returns an error if deleting files or saving manifest fails.
1603    pub async fn remove_apple_backend(&mut self) -> eyre::Result<()> {
1604        if let Some(backend) = self.apple_backend() {
1605            let path = backend.project_path().to_path_buf();
1606            self.remove_backend_relative_dir(&path).await?;
1607        }
1608        self.manifest.backends.clear_apple();
1609        self.remove_ffi_companion_if_unused().await?;
1610        self.save_manifest().await
1611    }
1612
1613    /// Remove Android backend configuration and generated files.
1614    ///
1615    /// # Errors
1616    /// Returns an error if deleting files or saving manifest fails.
1617    pub async fn remove_android_backend(&mut self) -> eyre::Result<()> {
1618        if let Some(backend) = self.android_backend() {
1619            let path = backend.project_path().clone();
1620            self.remove_backend_relative_dir(&path).await?;
1621        }
1622        self.manifest.backends.clear_android();
1623        self.remove_ffi_companion_if_unused().await?;
1624        self.save_manifest().await
1625    }
1626
1627    /// Remove GTK4 backend configuration and generated files.
1628    ///
1629    /// # Errors
1630    /// Returns an error if deleting files or saving manifest fails.
1631    pub async fn remove_gtk4_backend(&mut self) -> eyre::Result<()> {
1632        if let Some(backend) = self.gtk4_backend() {
1633            let path = backend.project_path().clone();
1634            self.remove_backend_relative_dir(&path).await?;
1635        }
1636        self.manifest.backends.clear_gtk4();
1637        self.save_manifest().await
1638    }
1639
1640    /// Remove `WinUI` backend configuration and generated files.
1641    ///
1642    /// # Errors
1643    /// Returns an error if deleting files or saving manifest fails.
1644    pub async fn remove_winui_backend(&mut self) -> eyre::Result<()> {
1645        if let Some(backend) = self.winui_backend() {
1646            let path = backend.project_path().clone();
1647            self.remove_backend_relative_dir(&path).await?;
1648        }
1649        self.manifest.backends.clear_winui();
1650        self.save_manifest().await
1651    }
1652
1653    /// Remove hydrolysis backend configuration and generated files.
1654    ///
1655    /// # Errors
1656    /// Returns an error if deleting files or saving manifest fails.
1657    pub async fn remove_hydrolysis_backend(&mut self) -> eyre::Result<()> {
1658        if let Some(backend) = self.hydrolysis_backend() {
1659            let path = backend.project_path().clone();
1660            self.remove_backend_relative_dir(&path).await?;
1661        }
1662        self.manifest.backends.clear_hydrolysis();
1663        self.save_manifest().await
1664    }
1665
1666    /// Remove ESP32 backend configuration and generated files.
1667    ///
1668    /// # Errors
1669    /// Returns an error if deleting files or saving manifest fails.
1670    pub async fn remove_esp32_backend(&mut self) -> eyre::Result<()> {
1671        if let Some(backend) = self.esp32_backend() {
1672            let path = backend.project_path().clone();
1673            self.remove_backend_relative_dir(&path).await?;
1674        }
1675        self.manifest.backends.clear_esp32();
1676        self.save_manifest().await
1677    }
1678
1679    /// Open a `WaterUI` project located at the specified path.
1680    ///
1681    /// This loads both the `Water.toml` manifest and the `Cargo.toml` file.
1682    /// For playground projects, backends are automatically initialized if not configured.
1683    ///
1684    /// # Errors
1685    /// - `FailToOpenProject::Manifest`: If there was an error opening the `Water.toml` manifest.
1686    /// - `FailToOpenProject::CargoManifest`: If there was an error reading the `Cargo.toml` file.
1687    /// - `FailToOpenProject::MissingCrateName`: If the crate name is missing in `Cargo.toml`.
1688    pub async fn open(path: impl AsRef<Path>) -> Result<Self, FailToOpenProject> {
1689        Self::open_with_mode(path, OpenMode::Full).await
1690    }
1691
1692    /// Open a project for preview dylib builds without initializing native app backends.
1693    ///
1694    /// Playground preview dylib builds only need the managed preview wrapper crate. Native
1695    /// backend initialization is reserved for support app projects that actually launch apps.
1696    ///
1697    /// # Errors
1698    /// - `FailToOpenProject::Manifest`: If there was an error opening the `Water.toml` manifest.
1699    /// - `FailToOpenProject::CargoManifest`: If there was an error reading the `Cargo.toml` file.
1700    /// - `FailToOpenProject::MissingCrateName`: If the crate name is missing in `Cargo.toml`.
1701    pub async fn open_for_preview_build(path: impl AsRef<Path>) -> Result<Self, FailToOpenProject> {
1702        Self::open_with_mode(path, OpenMode::PreviewBuild).await
1703    }
1704
1705    /// Make a local-checkout project's `[patch]` tables the checkout's.
1706    ///
1707    /// Cargo applies `[patch]` only from the workspace it builds, so a project
1708    /// on a `waterui_path` carries a copy of the checkout's tables, and the
1709    /// copy has to follow the checkout: a fork pin moves, an entry is added or
1710    /// dropped, and a project scaffolded earlier would otherwise build a graph
1711    /// the checkout no longer produces, silently. The manifest is rewritten
1712    /// only when the tables differ, so an up-to-date project stays untouched.
1713    ///
1714    /// A project that is itself a member of the checkout's workspace — every
1715    /// example and playground in this repository — needs no copy, because the
1716    /// tables Cargo reads are the checkout's own. Writing one anyway put a
1717    /// `[patch.crates-io]` table into a member manifest, where Cargo ignores it
1718    /// and says so on every single build.
1719    async fn refresh_local_patches(project_root: &Path, waterui_path: &Path) -> eyre::Result<()> {
1720        let project_root = project_root.to_path_buf();
1721        let waterui_path = waterui_path.to_path_buf();
1722        unblock(move || {
1723            let checkout = project_root.join(&waterui_path);
1724            let patch_root = templates::patch_manifest_dir(&project_root)?;
1725            if same_directory(&patch_root, &checkout)? {
1726                return Ok(());
1727            }
1728            if !same_directory(&patch_root, &project_root)? {
1729                // Cargo reads `[patch]` from `patch_root` and nothing this
1730                // function writes into the project could change that, so the
1731                // honest move is to say which manifest the tables belong in
1732                // rather than write a copy that is read by nobody.
1733                eyre::bail!(
1734                    "This project is a member of the Cargo workspace at {}, so Cargo reads \
1735                     [patch] from {} and ignores any copy here. Move the WaterUI checkout's \
1736                     [patch] tables — the ones in {} — into that workspace manifest, or take \
1737                     the project out of that workspace.",
1738                    patch_root.display(),
1739                    patch_root.join("Cargo.toml").display(),
1740                    checkout.join("Cargo.toml").display(),
1741                );
1742            }
1743            let cargo_path = project_root.join("Cargo.toml");
1744            let text = std::fs::read_to_string(&cargo_path)?;
1745            let current = CargoManifest::from_slice(text.as_bytes())?.patch;
1746            let next = templates::local_framework_patches(&project_root, &waterui_path)?;
1747            if current == next {
1748                return Ok(());
1749            }
1750            let mut document: toml_edit::DocumentMut = text.parse()?;
1751            crate::framework::rewrite_patch_tables(&mut document, &current, &next)?;
1752            std::fs::write(&cargo_path, document.to_string())?;
1753            info!(
1754                path = %cargo_path.display(),
1755                "Refreshed the [patch] tables from the local checkout"
1756            );
1757            Ok(())
1758        })
1759        .await
1760    }
1761
1762    #[allow(clippy::too_many_lines)]
1763    async fn open_with_mode(
1764        path: impl AsRef<Path>,
1765        open_mode: OpenMode,
1766    ) -> Result<Self, FailToOpenProject> {
1767        use crate::backend::Backend;
1768
1769        let total_start = std::time::Instant::now();
1770        let path = path.as_ref().to_path_buf();
1771
1772        let manifest_start = std::time::Instant::now();
1773        let manifest = Manifest::open(path.join("Water.toml"))
1774            .await
1775            .map_err(FailToOpenProject::Manifest)?;
1776        if let Some(framework) = &manifest.framework {
1777            framework
1778                .validate_cli()
1779                .map_err(FailToOpenProject::Framework)?;
1780        }
1781        if let Some(local) = &manifest.waterui_path {
1782            validate_local_cli(&path.join(local))
1783                .await
1784                .map_err(FailToOpenProject::Framework)?;
1785            Self::refresh_local_patches(&path, Path::new(local))
1786                .await
1787                .map_err(FailToOpenProject::LocalPatches)?;
1788        }
1789        info!(
1790            path = %path.display(),
1791            open_mode = ?open_mode,
1792            elapsed_ms = manifest_start.elapsed().as_millis(),
1793            "Project::open loaded Water.toml"
1794        );
1795
1796        let cargo_path = path.join("Cargo.toml");
1797
1798        let cargo_manifest_start = std::time::Instant::now();
1799        let cargo_manifest = unblock(move || CargoManifest::from_path(cargo_path))
1800            .await
1801            .map_err(FailToOpenProject::CargoManifest)?;
1802        info!(
1803            path = %path.display(),
1804            open_mode = ?open_mode,
1805            elapsed_ms = cargo_manifest_start.elapsed().as_millis(),
1806            "Project::open loaded Cargo.toml"
1807        );
1808        let crate_name = cargo_manifest
1809            .package
1810            .map(|p| p.name)
1811            .ok_or(FailToOpenProject::MissingCrateName)
1812            .and_then(|value| {
1813                CrateName::try_from(value).map_err(FailToOpenProject::InvalidCrateName)
1814            })?;
1815
1816        let is_playground = manifest.package.package_type == PackageType::Playground;
1817
1818        // Check that permissions are only set for playground projects
1819        if !is_playground && !manifest.permissions.is_empty() {
1820            return Err(FailToOpenProject::PermissionsNotAllowedInNonPlayground);
1821        }
1822
1823        // Playgrounds delegate backend projects to the CLI, so backend
1824        // scaffolding configuration is rejected. `[backends.esp32]` is the
1825        // exception: it is device configuration (chip, panel geometry,
1826        // bundled fonts) only the app author can supply, and its harness
1827        // still lives in the managed build cache.
1828        if is_playground && manifest.backends.configures_backend_projects() {
1829            return Err(FailToOpenProject::BackendsNotAllowedInPlayground);
1830        }
1831
1832        let cargo_layout = spawn_cargo_layout_resolution(
1833            &path,
1834            manifest.framework.clone(),
1835            manifest.waterui_path.is_some(),
1836        );
1837        cargo_layout
1838            .clone()
1839            .await
1840            .map_err(|error| FailToOpenProject::Framework(eyre::eyre!(error)))?;
1841
1842        let managed_backends_root = if is_playground {
1843            let build_cache_start = std::time::Instant::now();
1844            let root = crate::water_dir::ensure_project_build_cache(&path)
1845                .await
1846                .map_err(FailToOpenProject::BuildCache)?;
1847            info!(
1848                path = %path.display(),
1849                open_mode = ?open_mode,
1850                elapsed_ms = build_cache_start.elapsed().as_millis(),
1851                "Project::open ensured project build cache"
1852            );
1853            root
1854        } else {
1855            path.join(manifest.backends.path())
1856        };
1857
1858        let mut project = Self {
1859            root: path,
1860            manifest,
1861            crate_name,
1862            cargo_layout,
1863            linked_packages: Arc::new(async_lock::OnceCell::new()),
1864            enabled_features: Arc::new(async_lock::OnceCell::new()),
1865            managed_backends_root,
1866        };
1867
1868        // For playground projects, auto-initialize backends
1869        // Always re-scaffold templates on each run to pick up manifest changes (e.g., permissions)
1870        // Build cache (build/, .gradle/, DerivedData/) is preserved since scaffold only writes template files
1871        //
1872        // Skip backend initialization when:
1873        // 1. Running inside Xcode's sandboxed build script phase (WATERUI_SKIP_RUST_BUILD=1)
1874        // 2. Running inside any sandbox (sandbox-exec sets __XCODE_BUILT_PRODUCTS_DIR_PATHS or similar)
1875        // 3. Xcode is the current build tool (ACTION env var is set by Xcode)
1876        let skip_backend_init = std::env::var("WATERUI_SKIP_RUST_BUILD")
1877            .is_ok_and(|value| value == "1")
1878            || std::env::var("ACTION").is_ok() // Xcode sets this during builds
1879            || std::env::var("XCODE_PRODUCT_BUILD_VERSION").is_ok();
1880
1881        if is_playground && !skip_backend_init && open_mode == OpenMode::Full {
1882            let apple_backend_start = std::time::Instant::now();
1883            let apple_backend = AppleBackend::init(&project)
1884                .await
1885                .map_err(FailToOpenProject::BackendInit)?;
1886            info!(
1887                path = %project.root.display(),
1888                elapsed_ms = apple_backend_start.elapsed().as_millis(),
1889                "Project::open initialized Apple backend"
1890            );
1891            project.manifest.backends.set_apple(apple_backend);
1892
1893            let android_backend_start = std::time::Instant::now();
1894            let android_backend = AndroidBackend::init(&project)
1895                .await
1896                .map_err(FailToOpenProject::BackendInit)?;
1897            info!(
1898                path = %project.root.display(),
1899                elapsed_ms = android_backend_start.elapsed().as_millis(),
1900                "Project::open initialized Android backend"
1901            );
1902            project.manifest.backends.set_android(android_backend);
1903
1904            let ffi_companion_start = std::time::Instant::now();
1905            project
1906                .scaffold_ffi_companion()
1907                .await
1908                .map_err(FailToOpenProject::BackendInit)?;
1909            info!(
1910                path = %project.root.display(),
1911                elapsed_ms = ffi_companion_start.elapsed().as_millis(),
1912                "Project::open scaffolded native ffi companion"
1913            );
1914        }
1915
1916        if !is_playground
1917            && !skip_backend_init
1918            && open_mode == OpenMode::Full
1919            && (project.apple_backend().is_some() || project.android_backend().is_some())
1920        {
1921            let ffi_companion_start = std::time::Instant::now();
1922            project
1923                .scaffold_ffi_companion()
1924                .await
1925                .map_err(FailToOpenProject::BackendInit)?;
1926            info!(
1927                path = %project.root.display(),
1928                elapsed_ms = ffi_companion_start.elapsed().as_millis(),
1929                "Project::open refreshed native ffi companion"
1930            );
1931        }
1932
1933        info!(
1934            path = %project.root.display(),
1935            open_mode = ?open_mode,
1936            elapsed_ms = total_start.elapsed().as_millis(),
1937            "Project::open completed"
1938        );
1939
1940        Ok(project)
1941    }
1942}
1943
1944impl Project {
1945    async fn save_manifest(&self) -> eyre::Result<()> {
1946        self.manifest.save(&self.root).await.map_err(Into::into)
1947    }
1948
1949    async fn remove_backend_relative_dir(&self, relative_path: &Path) -> eyre::Result<()> {
1950        let backend_path = self.managed_backends_root.join(relative_path);
1951        if backend_path.exists() {
1952            smol::fs::remove_dir_all(&backend_path).await?;
1953        }
1954        Ok(())
1955    }
1956}
1957
1958async fn apply_channel_selection(
1959    root: &Path,
1960    framework: ResolvedFramework,
1961    updates: Vec<(PathBuf, Option<Vec<u8>>)>,
1962) -> eyre::Result<()> {
1963    let mut previous = BTreeMap::new();
1964    for file in updates
1965        .iter()
1966        .map(|(file, _)| file.clone())
1967        .chain([root.join("Cargo.lock")])
1968    {
1969        let contents = match smol::fs::read(&file).await {
1970            Ok(contents) => Some(contents),
1971            Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
1972            Err(error) => return Err(error.into()),
1973        };
1974        previous.insert(file, contents);
1975    }
1976    let result = async {
1977        for (file, contents) in &updates {
1978            write_channel_file(file, contents.as_deref()).await?;
1979        }
1980        resolve_cargo_layout(root, Some(framework), CargoResolution::Update).await?;
1981        Ok(())
1982    }
1983    .await;
1984    if let Err(error) = result {
1985        for (file, contents) in previous {
1986            write_channel_file(&file, contents.as_deref())
1987                .await
1988                .map_err(|restore| {
1989                    eyre::eyre!("{error}; could not restore {}: {restore}", file.display())
1990                })?;
1991        }
1992        return Err(error);
1993    }
1994    Ok(())
1995}
1996
1997async fn write_channel_file(path: &Path, contents: Option<&[u8]>) -> std::io::Result<()> {
1998    match contents {
1999        Some(contents) => smol::fs::write(path, contents).await,
2000        None => match smol::fs::remove_file(path).await {
2001            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
2002            result => result,
2003        },
2004    }
2005}
2006
2007async fn resolve_cargo_layout(
2008    current_dir: &Path,
2009    framework: Option<ResolvedFramework>,
2010    mode: CargoResolution,
2011) -> eyre::Result<CargoLayout> {
2012    let root = current_dir.to_path_buf();
2013    let metadata = unblock(move || {
2014        let mut command = cargo_metadata::MetadataCommand::new();
2015        command.current_dir(root);
2016        match mode {
2017            CargoResolution::Local => {
2018                command.no_deps();
2019            }
2020            CargoResolution::Locked => {
2021                command.other_options(vec!["--locked".to_string()]);
2022            }
2023            CargoResolution::Update => {}
2024        }
2025        command.exec()
2026    })
2027    .await?;
2028    validate_resolved_cli(&metadata)?;
2029    if let Some(framework) = framework
2030        && framework.channel() != Some(FrameworkChannel::Stable)
2031    {
2032        let lockfile = smol::fs::read(current_dir.join("Water.lock")).await?;
2033        framework.validate_dependencies(&metadata, &lockfile)?;
2034    }
2035
2036    Ok(CargoLayout {
2037        target_dir: metadata.target_directory.into_std_path_buf(),
2038        workspace_root: metadata.workspace_root.into_std_path_buf(),
2039    })
2040}
2041
2042/// Run `cargo tree` for the application package rooted at `project_root`'s
2043/// manifest, over the given edge kinds, and return the `{p}`-formatted tree.
2044///
2045/// `locked` passes `--locked` to the resolve: trees that are read-only input —
2046/// the shared pinned-framework checkout — must fail loudly on a stale
2047/// committed lockfile instead of letting cargo rewrite it in place.
2048async fn cargo_tree(project_root: &Path, edges: &str, locked: bool) -> eyre::Result<String> {
2049    // `dunce`, not `std::fs::canonicalize`: on Windows the standard one returns
2050    // an extended-length path (`\\?\D:\...`), while `cargo metadata` reports the
2051    // plain one, so comparing the two never matched and the package below was
2052    // always "omitted" (part of #152). Canonicalize before invoking metadata,
2053    // not just on the looked-up side: metadata echoes the manifest path it is
2054    // given, so under a symlinked `TMPDIR` (`/var` → `/private/var` on macOS)
2055    // a non-canonical input can never match what metadata reports.
2056    let application_manifest = dunce::canonicalize(project_root.join("Cargo.toml"))?;
2057    let metadata_manifest = application_manifest.clone();
2058    let metadata = unblock(move || {
2059        let mut command = cargo_metadata::MetadataCommand::new();
2060        command.no_deps().manifest_path(metadata_manifest);
2061        if locked {
2062            command.other_options(vec!["--locked".to_string()]);
2063        }
2064        command.exec()
2065    })
2066    .await?;
2067    let root = metadata
2068        .packages
2069        .iter()
2070        .find(|package| package.manifest_path.as_std_path() == application_manifest)
2071        .ok_or_else(|| {
2072            eyre::eyre!(
2073                "Cargo metadata omitted the application package at {}",
2074                application_manifest.display()
2075            )
2076        })?;
2077    let package_spec = root.id.to_string();
2078    let mut tree = Command::new("cargo");
2079    tree.arg("tree")
2080        .arg("--manifest-path")
2081        .arg(&application_manifest)
2082        .arg("--package")
2083        .arg(package_spec)
2084        .arg("--edges")
2085        .arg(edges)
2086        .arg("--prefix")
2087        .arg("none")
2088        .arg("--format")
2089        .arg("{p}")
2090        .current_dir(project_root);
2091    if locked {
2092        tree.arg("--locked");
2093    }
2094    let output = tree.output().await?;
2095    if !output.status.success() {
2096        return Err(eyre::eyre!(
2097            "failed to resolve runtime dependency graph for {}: {}",
2098            application_manifest.display(),
2099            String::from_utf8_lossy(&output.stderr).trim()
2100        ));
2101    }
2102
2103    String::from_utf8(output.stdout)
2104        .map_err(|error| eyre::eyre!("Cargo runtime dependency graph is not UTF-8: {error}"))
2105}
2106
2107async fn resolve_linked_runtime_packages(
2108    project_root: PathBuf,
2109    locked: bool,
2110) -> eyre::Result<BTreeMap<String, String>> {
2111    let tree = cargo_tree(&project_root, "normal", locked).await?;
2112    let mut linked = BTreeMap::new();
2113    for package in tree.lines() {
2114        let name = package
2115            .split_ascii_whitespace()
2116            .next()
2117            .ok_or_else(|| eyre::eyre!("Cargo emitted an empty runtime dependency entry"))?;
2118        linked.insert(name.to_string(), package.to_string());
2119    }
2120
2121    Ok(linked)
2122}
2123
2124/// Feature names turned on inside the application's subtree. With `--edges
2125/// features`, `cargo tree` reports each enabled feature as a
2126/// `<package> feature "<name>"` node; only the names are kept, since the
2127/// question asked of this set is always "is a feature named X enabled".
2128async fn resolve_enabled_features(
2129    project_root: PathBuf,
2130    locked: bool,
2131) -> eyre::Result<BTreeSet<String>> {
2132    let tree = cargo_tree(&project_root, "features", locked).await?;
2133    let mut features = BTreeSet::new();
2134    for node in tree.lines() {
2135        if let Some(feature) = node
2136            .split_once(" feature \"")
2137            .and_then(|(_, rest)| rest.strip_suffix('"'))
2138        {
2139            features.insert(feature.to_string());
2140        }
2141    }
2142    Ok(features)
2143}
2144
2145use std::{
2146    collections::{BTreeMap, BTreeSet},
2147    path::{Path, PathBuf},
2148    sync::Arc,
2149};
2150
2151use serde::{Deserialize, Serialize};
2152use smol::{fs::read_to_string, process::Command, unblock};
2153use waterui_assets_planner::{LaunchConfig, ThemeConfig};
2154
2155use crate::{
2156    android::{backend::AndroidBackend, device::AndroidAbiProvider, platform::AndroidPlatform},
2157    apple::backend::AppleBackend,
2158    backend::{Backend, Backends},
2159    build::{BuildOptions, BuildProfile},
2160    device::{Artifact, Device, FailToRun, RunOptions, Running},
2161    platform::{PackageOptions, TargetBackend, TargetPlatform},
2162    project_types::{BundleIdentifier, CrateName, PermissionKey, generated_crate_name},
2163    templates::{self, TemplateContext},
2164    utils::command,
2165    web,
2166};
2167
2168/// Configuration for a `WaterUI` project persisted to `Water.toml`.
2169#[derive(Debug, Serialize, Deserialize, Clone)]
2170pub struct Manifest {
2171    /// Package information.
2172    pub package: Package,
2173    /// Backend configurations for various platforms.
2174    #[serde(default, skip_serializing_if = "Backends::is_empty")]
2175    pub backends: Backends,
2176    /// Web engine selected for the standard `WebView` component.
2177    /// Path to local `WaterUI` repository for dev mode.
2178    /// When set, all backends will use this path instead of the published versions.
2179    #[serde(skip_serializing_if = "Option::is_none")]
2180    pub waterui_path: Option<String>,
2181    /// Exact framework and backend selection, resolved only by explicit version operations.
2182    #[serde(default, skip_serializing_if = "Option::is_none")]
2183    pub framework: Option<ResolvedFramework>,
2184    /// Permission configuration for playground projects.
2185    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2186    pub permissions: BTreeMap<PermissionKey, PermissionEntry>,
2187    /// App-only configuration.
2188    #[serde(default, skip_serializing_if = "Option::is_none")]
2189    pub app: Option<AppConfig>,
2190    /// Cross-platform app theme slots.
2191    #[serde(default, skip_serializing_if = "Option::is_none")]
2192    pub theme: Option<ThemeConfig>,
2193    /// The launch screen shown until the app's first frame.
2194    #[serde(default, skip_serializing_if = "Option::is_none")]
2195    pub launch: Option<LaunchConfig>,
2196    /// Web-frontend toolchain declarations (`[web]`); only the CLI reads this.
2197    #[serde(default, skip_serializing_if = "Option::is_none")]
2198    pub web: Option<web::WebConfig>,
2199}
2200
2201/// Permission entry for playground projects.
2202#[derive(Debug, Serialize, Deserialize, Clone)]
2203pub struct PermissionEntry {
2204    enable: bool,
2205    /// Explain why this permission is needed.
2206    description: String,
2207}
2208
2209impl PermissionEntry {
2210    /// Create an enabled permission entry with the given rationale.
2211    #[must_use]
2212    pub fn enabled(description: impl Into<String>) -> Self {
2213        Self {
2214            enable: true,
2215            description: description.into(),
2216        }
2217    }
2218
2219    /// Check if this permission is enabled.
2220    #[must_use]
2221    pub const fn is_enabled(&self) -> bool {
2222        self.enable
2223    }
2224
2225    /// Get the description of why this permission is needed.
2226    #[must_use]
2227    pub fn description(&self) -> &str {
2228        &self.description
2229    }
2230}
2231
2232/// Errors that can occur when opening a `Water.toml` manifest file.
2233#[derive(Debug, thiserror::Error)]
2234pub enum FailToOpenManifest {
2235    /// Failed to read the manifest file from the filesystem.
2236    #[error("Failed to read manifest file: {0}")]
2237    ReadError(std::io::Error),
2238    /// The manifest file is invalid or malformed.
2239    #[error("Invalid manifest file: {0}")]
2240    InvalidManifest(toml::de::Error),
2241
2242    /// The manifest file was not found at the specified path.
2243    #[error("Manifest file not found at the specified path")]
2244    NotFound,
2245}
2246
2247/// Errors that can occur when saving a `Water.toml` manifest file.
2248#[derive(Debug, thiserror::Error)]
2249pub enum FailToSaveManifest {
2250    /// Failed to serialize the manifest to TOML.
2251    #[error("Failed to serialize manifest: {0}")]
2252    Serialize(toml::ser::Error),
2253    /// Failed to write the manifest file to disk.
2254    #[error("Failed to write manifest file: {0}")]
2255    Write(std::io::Error),
2256}
2257impl Manifest {
2258    /// Open and parse a `Water.toml` manifest file from the specified path.
2259    ///
2260    /// # Errors
2261    /// - `FailToOpenManifest::ReadError`: If there was an error reading the file.
2262    /// - `FailToOpenManifest::InvalidManifest`: If the file contents are not valid TOML.
2263    /// - `FailToOpenManifest::NotFound`: If the file does not exist at the specified path.
2264    pub async fn open(path: impl AsRef<Path>) -> Result<Self, FailToOpenManifest> {
2265        let path = path.as_ref();
2266        let result = read_to_string(path).await;
2267
2268        match result {
2269            Ok(c) => toml::from_str(&c).map_err(FailToOpenManifest::InvalidManifest),
2270            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(FailToOpenManifest::NotFound),
2271            Err(e) => Err(FailToOpenManifest::ReadError(e)),
2272        }
2273    }
2274
2275    /// Save the manifest to a `Water.toml` file at the specified directory.
2276    ///
2277    /// # Errors
2278    /// - If there was an error serializing the manifest to TOML.
2279    /// - If there was an error writing the file.
2280    pub async fn save(&self, dir: impl AsRef<Path>) -> Result<(), FailToSaveManifest> {
2281        let path = dir.as_ref().join("Water.toml");
2282        let content = toml::to_string_pretty(self).map_err(FailToSaveManifest::Serialize)?;
2283        smol::fs::write(&path, content)
2284            .await
2285            .map_err(FailToSaveManifest::Write)
2286    }
2287
2288    /// Create a new `Manifest` with the specified package information.
2289    #[must_use]
2290    pub fn new(package: Package) -> Self {
2291        Self {
2292            package,
2293            backends: Backends::default(),
2294            waterui_path: None,
2295            framework: None,
2296            permissions: BTreeMap::default(),
2297            app: None,
2298            theme: None,
2299            launch: None,
2300            web: None,
2301        }
2302    }
2303}
2304
2305/// The engine that draws this application's standard `WebView`.
2306#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2307pub enum ResolvedWebViewBackend {
2308    /// Platform-provided `WebView`.
2309    System,
2310    /// Bundled WPE `WebKit` runtime.
2311    Wpe,
2312    /// Bundled Chromium Embedded Framework runtime.
2313    Cef,
2314}
2315
2316/// Browser engines that must be staged for one resolved application graph.
2317#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2318pub struct BrowserRuntimePlan {
2319    /// Standard `WebView` engine, when `waterui-webview` is linked.
2320    pub webview: Option<ResolvedWebViewBackend>,
2321    /// Whether the independent full Chromium component is linked.
2322    pub chromium: bool,
2323}
2324
2325impl BrowserRuntimePlan {
2326    /// Returns whether this application requires a packaged CEF runtime and
2327    /// subprocess helper.
2328    #[must_use]
2329    pub const fn requires_cef(self) -> bool {
2330        self.chromium || matches!(self.webview, Some(ResolvedWebViewBackend::Cef))
2331    }
2332}
2333
2334impl ResolvedWebViewBackend {
2335    /// Return whether this engine can be hosted by a platform and backend pair.
2336    #[must_use]
2337    pub const fn supports(self, platform: TargetPlatform, backend: TargetBackend) -> bool {
2338        match self {
2339            Self::System => matches!(
2340                (platform, backend),
2341                (
2342                    TargetPlatform::MacOS,
2343                    TargetBackend::Apple | TargetBackend::Hydrolysis
2344                ) | (
2345                    TargetPlatform::IOS
2346                        | TargetPlatform::IOSSimulator
2347                        | TargetPlatform::VisionOS
2348                        | TargetPlatform::VisionOSSimulator,
2349                    TargetBackend::Apple
2350                ) | (TargetPlatform::Android, TargetBackend::Android)
2351                    | (TargetPlatform::Linux, TargetBackend::Gtk4)
2352                    | (TargetPlatform::Web, TargetBackend::Hydrolysis)
2353            ),
2354            Self::Wpe => {
2355                matches!(platform, TargetPlatform::Linux)
2356                    && matches!(backend, TargetBackend::Gtk4 | TargetBackend::Hydrolysis)
2357            }
2358            Self::Cef => cef_is_supported(platform, backend),
2359        }
2360    }
2361
2362    /// Returns this engine, or an error naming what cannot host it.
2363    ///
2364    /// # Errors
2365    ///
2366    /// Returns an error when this platform and backend pair cannot host the
2367    /// engine the application selected.
2368    pub const fn validate(
2369        self,
2370        platform: TargetPlatform,
2371        backend: TargetBackend,
2372    ) -> Result<Self, UnsupportedWebViewBackend> {
2373        if self.supports(platform, backend) {
2374            Ok(self)
2375        } else {
2376            Err(UnsupportedWebViewBackend {
2377                resolved: self,
2378                platform,
2379                backend,
2380            })
2381        }
2382    }
2383
2384    /// Stable lowercase name used for Cargo features, runtime manifests, and diagnostics.
2385    #[must_use]
2386    pub const fn as_str(self) -> &'static str {
2387        match self {
2388            Self::System => "system",
2389            Self::Wpe => "wpe",
2390            Self::Cef => "cef",
2391        }
2392    }
2393}
2394
2395const fn cef_is_supported(platform: TargetPlatform, backend: TargetBackend) -> bool {
2396    !matches!(backend, TargetBackend::Dew)
2397        && matches!(
2398            platform,
2399            TargetPlatform::MacOS | TargetPlatform::Linux | TargetPlatform::Windows
2400        )
2401}
2402
2403/// Error returned for an unsupported `WebView` engine/platform/backend combination.
2404#[derive(Debug, thiserror::Error)]
2405#[error(
2406    "this application's WebView engine resolves to {resolved:?}, which is unsupported for \
2407     platform {platform:?} with backend {backend:?}. The engine follows the application's \
2408     dependencies: link waterui-browser-cef or waterui-browser-wpe to select one, or \
2409     neither to use the engine this platform bridges."
2410)]
2411pub struct UnsupportedWebViewBackend {
2412    resolved: ResolvedWebViewBackend,
2413    platform: TargetPlatform,
2414    backend: TargetBackend,
2415}
2416
2417/// App-specific configuration in `Water.toml`.
2418#[derive(Debug, Serialize, Deserialize, Clone, Default)]
2419pub struct AppConfig {
2420    /// Optional crate name overrides.
2421    #[serde(default, skip_serializing_if = "Option::is_none")]
2422    pub crates: Option<AppCrates>,
2423}
2424
2425/// Crate name overrides for app mode.
2426#[derive(Debug, Serialize, Deserialize, Clone, Default)]
2427pub struct AppCrates {
2428    /// Optional override crate name for generated FFI crate.
2429    #[serde(default, skip_serializing_if = "Option::is_none")]
2430    pub ffi: Option<CrateName>,
2431    /// Optional override crate name for generated GTK backend crate.
2432    #[serde(default, skip_serializing_if = "Option::is_none")]
2433    pub gtk: Option<CrateName>,
2434    /// Optional override crate name for generated hydrolysis backend crate.
2435    #[serde(default, skip_serializing_if = "Option::is_none")]
2436    pub hydrolysis: Option<CrateName>,
2437    /// Optional override crate name for generated `WinUI` backend crate.
2438    #[serde(default, skip_serializing_if = "Option::is_none")]
2439    pub winui: Option<CrateName>,
2440}
2441
2442/// `[package]` section in `Water.toml`.
2443#[derive(Debug, Serialize, Deserialize, Clone)]
2444pub struct Package {
2445    /// Type of the package (e.g., "app").
2446    #[serde(rename = "type")]
2447    pub package_type: PackageType,
2448    /// Human-readable name of the application (e.g., "Water Demo").
2449    pub name: String,
2450    /// Bundle identifier for the application (e.g., "dev.waterui.waterdemo").
2451    pub bundle_identifier: BundleIdentifier,
2452    /// Path to assets directory relative to project root. Defaults to "assets".
2453    #[serde(
2454        default = "default_assets_path",
2455        skip_serializing_if = "is_default_assets_path"
2456    )]
2457    pub assets_path: String,
2458    /// Whether to build as an accessory (headless) app on macOS.
2459    #[serde(default, skip_serializing_if = "is_false")]
2460    pub accessory: bool,
2461}
2462
2463/// Reads the `package.name` of a project's `Cargo.toml` — the crate name the
2464/// generated backends and preview symbols build on.
2465///
2466/// Lighter than [`Project::open`]: this only parses the manifest, so callers
2467/// that need just the crate name (the `water preview`/`water mcp` entry
2468/// points) do not pay for a full project open.
2469///
2470/// # Errors
2471/// Returns an error if `Cargo.toml` cannot be read or has no `package.name`.
2472pub async fn read_project_crate_name(project_path: &Path) -> eyre::Result<String> {
2473    let cargo_toml = project_path.join("Cargo.toml");
2474    let cargo_content = smol::fs::read_to_string(&cargo_toml).await?;
2475    let cargo: toml::Table = cargo_content.parse()?;
2476    cargo
2477        .get("package")
2478        .and_then(|p| p.get("name"))
2479        .and_then(|n| n.as_str())
2480        .map(ToString::to_string)
2481        .ok_or_else(|| eyre::eyre!("Could not find package name in Cargo.toml"))
2482}
2483
2484/// Whether two paths name the same directory on disk.
2485///
2486/// Compared after canonicalization, because the two sides come from different
2487/// places — one walked up from the project, one joined from a relative
2488/// `waterui_path` — and `examples/filter/../..` is the repository root however
2489/// it is spelled.
2490fn same_directory(left: &Path, right: &Path) -> std::io::Result<bool> {
2491    Ok(std::fs::canonicalize(left)? == std::fs::canonicalize(right)?)
2492}
2493
2494fn default_assets_path() -> String {
2495    "assets".to_string()
2496}
2497
2498fn is_default_assets_path(path: &str) -> bool {
2499    path == "assets"
2500}
2501
2502#[allow(clippy::trivially_copy_pass_by_ref)]
2503const fn is_false(value: &bool) -> bool {
2504    !*value
2505}
2506
2507/// Package type indicating what kind of project this is.
2508#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq)]
2509#[serde(rename_all = "lowercase")]
2510pub enum PackageType {
2511    /// A standalone application with platform-specific backends.
2512    #[default]
2513    App,
2514    /// A playground project for quick experimentation.
2515    /// Platform projects are created in a temporary directory.
2516    Playground,
2517}
2518
2519#[cfg(test)]
2520mod channel_tests {
2521    use super::*;
2522
2523    #[test]
2524    fn local_framework_requirement_is_checked_before_project_io() {
2525        smol::block_on(async {
2526            let directory = tempfile::tempdir().unwrap();
2527            let framework_root = directory.path().join("framework");
2528            let project_root = directory.path().join("consumer");
2529            smol::fs::create_dir(&framework_root).await.unwrap();
2530            let mut minimum: cargo_toml::SemVer = env!("CARGO_PKG_VERSION").parse().unwrap();
2531            minimum.major += 1;
2532            let mut metadata = toml::toml! {
2533                [package.metadata.waterui]
2534                minimum-cli-version = "0.1.4"
2535                android-min-api-level = 26
2536            };
2537            metadata["package"]["metadata"]["waterui"]["minimum-cli-version"] =
2538                toml::Value::String(minimum.to_string());
2539            smol::fs::write(
2540                framework_root.join("Cargo.toml"),
2541                toml::to_string(&metadata).unwrap(),
2542            )
2543            .await
2544            .unwrap();
2545            let bundle_identifier =
2546                BundleIdentifier::try_from("dev.waterui.compatibility").unwrap();
2547            let options = CreateOptions {
2548                name: "Compatibility".into(),
2549                bundle_identifier: bundle_identifier.clone(),
2550                package_type: PackageType::Playground,
2551                waterui_path: Some(framework_root),
2552                channel: None,
2553                framework_manifest: None,
2554                framework: None,
2555                author: String::new(),
2556                backends: Vec::new(),
2557                web: None,
2558            };
2559            let error = Project::create(&project_root, options)
2560                .await
2561                .unwrap_err()
2562                .to_string();
2563            assert!(error.contains(&format!("requires waterui-cli >= {minimum}")));
2564            assert!(error.contains(&format!(
2565                "cargo install waterui-cli --git {} --locked",
2566                env!("CARGO_PKG_REPOSITORY")
2567            )));
2568            assert!(!project_root.exists());
2569
2570            smol::fs::create_dir(&project_root).await.unwrap();
2571            let mut manifest = Manifest::new(Package {
2572                name: "Compatibility".into(),
2573                bundle_identifier,
2574                package_type: PackageType::Playground,
2575                assets_path: default_assets_path(),
2576                accessory: false,
2577            });
2578            manifest.waterui_path = Some("../framework".into());
2579            manifest.save(&project_root).await.unwrap();
2580            let error = Project::open_for_preview_build(&project_root)
2581                .await
2582                .unwrap_err()
2583                .to_string();
2584            assert!(error.contains(&format!("requires waterui-cli >= {minimum}")));
2585            assert!(!project_root.join("Cargo.lock").exists());
2586        });
2587    }
2588
2589    #[test]
2590    fn failed_channel_selection_preserves_project_files() {
2591        smol::block_on(async {
2592            let directory = tempfile::tempdir().unwrap();
2593            let root = directory.path();
2594            let originals = [
2595                ("Cargo.toml", b"original manifest".as_slice()),
2596                ("Cargo.lock", b"original dependency lock".as_slice()),
2597                ("Water.toml", b"original project configuration".as_slice()),
2598            ];
2599            for (name, contents) in originals {
2600                smol::fs::write(root.join(name), contents).await.unwrap();
2601            }
2602            let updates = ["Cargo.toml", "Cargo.lock", "Water.toml", "Water.lock"]
2603                .into_iter()
2604                .map(|name| (root.join(name), Some(b"invalid selected manifest".to_vec())))
2605                .collect();
2606            assert!(
2607                apply_channel_selection(
2608                    root,
2609                    crate::framework::test_fixtures::stable_framework(),
2610                    updates
2611                )
2612                .await
2613                .is_err()
2614            );
2615            for (name, contents) in originals {
2616                assert_eq!(smol::fs::read(root.join(name)).await.unwrap(), contents);
2617            }
2618            assert!(!root.join("Water.lock").exists());
2619        });
2620    }
2621}
2622
2623#[cfg(test)]
2624mod webview_backend_tests {
2625    use super::{
2626        ResolvedWebViewBackend, TargetBackend, TargetPlatform, resolve_enabled_features,
2627        resolve_linked_runtime_packages,
2628    };
2629
2630    /// An application that links no engine crate uses whatever the platform
2631    /// bridges, and the bridge is not everywhere: Linux outside GTK has none, so
2632    /// such a build is refused with an explanation instead of producing a
2633    /// contentless web view at runtime.
2634    #[test]
2635    fn the_platform_bridge_is_the_selection_without_an_engine_crate() {
2636        assert_eq!(
2637            ResolvedWebViewBackend::System
2638                .validate(TargetPlatform::MacOS, TargetBackend::Hydrolysis)
2639                .expect("macOS Hydrolysis bridges WKWebView"),
2640            ResolvedWebViewBackend::System
2641        );
2642        assert_eq!(
2643            ResolvedWebViewBackend::System
2644                .validate(TargetPlatform::Linux, TargetBackend::Gtk4)
2645                .expect("GTK bridges WebKitGTK"),
2646            ResolvedWebViewBackend::System
2647        );
2648        assert!(
2649            ResolvedWebViewBackend::System
2650                .validate(TargetPlatform::Linux, TargetBackend::Hydrolysis)
2651                .is_err()
2652        );
2653        assert!(
2654            ResolvedWebViewBackend::System
2655                .validate(TargetPlatform::Windows, TargetBackend::Hydrolysis)
2656                .is_err()
2657        );
2658    }
2659
2660    #[test]
2661    fn unsupported_engine_combinations_fail_before_build() {
2662        assert!(
2663            ResolvedWebViewBackend::Wpe
2664                .validate(TargetPlatform::MacOS, TargetBackend::Hydrolysis)
2665                .is_err()
2666        );
2667        assert!(
2668            ResolvedWebViewBackend::Cef
2669                .validate(TargetPlatform::Android, TargetBackend::Android)
2670                .is_err()
2671        );
2672        assert_eq!(
2673            ResolvedWebViewBackend::Cef
2674                .validate(TargetPlatform::MacOS, TargetBackend::Apple)
2675                .expect("CEF must compose with the native Apple renderer on macOS"),
2676            ResolvedWebViewBackend::Cef
2677        );
2678    }
2679
2680    #[test]
2681    fn cef_is_available_to_every_non_dew_backend_on_desktop_platforms() {
2682        for backend in [
2683            TargetBackend::Apple,
2684            TargetBackend::Android,
2685            TargetBackend::Gtk4,
2686            TargetBackend::Hydrolysis,
2687        ] {
2688            for platform in [
2689                TargetPlatform::MacOS,
2690                TargetPlatform::Linux,
2691                TargetPlatform::Windows,
2692            ] {
2693                assert_eq!(
2694                    ResolvedWebViewBackend::Cef
2695                        .validate(platform, backend)
2696                        .expect("CEF availability must not depend on the WaterUI backend"),
2697                    ResolvedWebViewBackend::Cef
2698                );
2699            }
2700        }
2701    }
2702
2703    #[test]
2704    fn cef_rejects_dew_and_platforms_without_cef_distributions() {
2705        for platform in [
2706            TargetPlatform::MacOS,
2707            TargetPlatform::Linux,
2708            TargetPlatform::Windows,
2709        ] {
2710            assert!(
2711                ResolvedWebViewBackend::Cef
2712                    .validate(platform, TargetBackend::Dew)
2713                    .is_err()
2714            );
2715        }
2716        for (platform, backend) in [
2717            (TargetPlatform::Android, TargetBackend::Android),
2718            (TargetPlatform::IOS, TargetBackend::Apple),
2719            (TargetPlatform::Web, TargetBackend::Hydrolysis),
2720        ] {
2721            assert!(
2722                ResolvedWebViewBackend::Cef
2723                    .validate(platform, backend)
2724                    .is_err()
2725            );
2726        }
2727    }
2728
2729    /// The engine is read out of the application's own graph, so the examples
2730    /// are the test: the CEF `WebView` example links `waterui-browser-cef` and
2731    /// the shared system-`WebView` example links no engine at all. The
2732    /// examples live in the framework repository — this crate builds against a
2733    /// pinned `water-rs/waterui` revision, and the test clones it on demand.
2734    #[test]
2735    #[ignore = "clones the pinned framework revision"]
2736    fn runtime_graph_is_scoped_to_the_selected_application() {
2737        let repository = crate::pinned_framework::checkout();
2738        let chromium = smol::block_on(resolve_linked_runtime_packages(
2739            repository.join("examples/chromium"),
2740            true,
2741        ))
2742        .expect("Chromium example runtime graph must resolve");
2743        assert!(
2744            chromium.contains_key("waterui-chromium"),
2745            "Chromium example graph: {chromium:#?}"
2746        );
2747        // The Chromium example links the engine it draws through, and nothing
2748        // else: no second engine, and no `waterui` facade `webview` feature.
2749        assert!(
2750            chromium.contains_key("waterui-browser-cef"),
2751            "Chromium example graph: {chromium:#?}"
2752        );
2753        assert!(
2754            !chromium.contains_key("waterui-browser-wpe"),
2755            "Chromium example graph: {chromium:#?}"
2756        );
2757        // A Chromium-only application shows no standard `WebView`, so
2758        // `webview_enabled` must be false for it: the Apple scaffold reads this
2759        // graph to decide whether to link the `WaterUICefWebView` framework.
2760        // The `waterui-webview` package is present — `waterui-chromium` links
2761        // it for the shared asset-server types — so the signal is the `webview`
2762        // feature, which nothing in this subtree turns on.
2763        assert!(
2764            chromium.contains_key("waterui-webview"),
2765            "waterui-chromium shares the webview asset-server types: {chromium:#?}"
2766        );
2767        let chromium_features = smol::block_on(resolve_enabled_features(
2768            repository.join("examples/chromium"),
2769            true,
2770        ))
2771        .expect("Chromium example feature graph must resolve");
2772        assert!(
2773            !chromium_features.contains("webview"),
2774            "a Chromium-only application must not enable the standard WebView \
2775             component: {chromium_features:#?}"
2776        );
2777
2778        let webview = smol::block_on(resolve_linked_runtime_packages(
2779            repository.join("examples/webview"),
2780            true,
2781        ))
2782        .expect("WebView example runtime graph must resolve");
2783        assert!(
2784            webview.contains_key("waterui-webview"),
2785            "WebView example graph: {webview:#?}"
2786        );
2787        let webview_features = smol::block_on(resolve_enabled_features(
2788            repository.join("examples/webview"),
2789            true,
2790        ))
2791        .expect("WebView example feature graph must resolve");
2792        assert!(
2793            webview_features.contains("webview"),
2794            "the WebView example enables the facade `webview` feature: {webview_features:#?}"
2795        );
2796        assert!(
2797            !webview.contains_key("waterui-browser-cef"),
2798            "WebView example graph: {webview:#?}"
2799        );
2800        assert!(
2801            !webview.contains_key("waterui-chromium"),
2802            "WebView example graph: {webview:#?}"
2803        );
2804
2805        let cef_webview = smol::block_on(resolve_linked_runtime_packages(
2806            repository.join("examples/webview-cef"),
2807            true,
2808        ))
2809        .expect("CEF WebView example runtime graph must resolve");
2810        assert!(
2811            cef_webview.contains_key("waterui-browser-cef"),
2812            "CEF WebView example graph: {cef_webview:#?}"
2813        );
2814        assert!(
2815            !cef_webview.contains_key("waterui-browser-wpe"),
2816            "CEF WebView example graph: {cef_webview:#?}"
2817        );
2818    }
2819
2820    /// The `map` capability — the Apple `MapKit` bridge's `-DWATERUI_MAP`, and
2821    /// the FFI's `map` feature — is read off the application's own graph, the
2822    /// same way the browser engine is. `waterui-map` is a component crate an
2823    /// application depends on directly; no facade feature announces it any
2824    /// more, so linking it is what the capability has to see. The examples
2825    /// live in the framework repository — this crate builds against a pinned
2826    /// `water-rs/waterui` revision, and the test clones it on demand.
2827    #[test]
2828    #[ignore = "clones the pinned framework revision"]
2829    fn the_map_capability_is_read_from_the_application_graph() {
2830        let repository = crate::pinned_framework::checkout();
2831
2832        let map = smol::block_on(resolve_linked_runtime_packages(
2833            repository.join("examples/map"),
2834            true,
2835        ))
2836        .expect("map example runtime graph must resolve");
2837        assert!(
2838            map.contains_key("waterui-map"),
2839            "map example graph: {map:#?}"
2840        );
2841
2842        let webview = smol::block_on(resolve_linked_runtime_packages(
2843            repository.join("examples/webview"),
2844            true,
2845        ))
2846        .expect("WebView example runtime graph must resolve");
2847        assert!(
2848            !webview.contains_key("waterui-map"),
2849            "an application that shows no map must not carry the map stack: {webview:#?}"
2850        );
2851    }
2852}
2853
2854#[cfg(test)]
2855mod scaffold_tests {
2856    use std::path::Path;
2857
2858    use super::{BundleIdentifier, CreateOptions, PackageType, Project, TargetBackend};
2859
2860    /// The documented `assets!` workflow requires the assets root to exist: the
2861    /// planner walks it recursively, so a missing directory fails the first
2862    /// `assets!` call. `water create` must therefore produce it, tracked, and at
2863    /// exactly the path the generated `Water.toml` declares.
2864    #[test]
2865    fn create_scaffolds_the_assets_directory_declared_by_the_manifest() {
2866        let dir = tempfile::tempdir().expect("temp dir");
2867        let root = dir.path().join("water-example");
2868
2869        let project = smol::block_on(Project::create(
2870            &root,
2871            CreateOptions {
2872                name: "Water Example".to_string(),
2873                bundle_identifier: BundleIdentifier::try_from("dev.waterui.waterexample")
2874                    .expect("bundle identifier"),
2875                package_type: PackageType::Playground,
2876                waterui_path: None,
2877                channel: None,
2878                framework_manifest: None,
2879                // A channel resolution would fetch the newest release from
2880                // GitHub; a unit test resolves a fixture in place instead.
2881                framework: Some(crate::framework::test_fixtures::stable_framework()),
2882                author: "Lexo Liu".to_string(),
2883                backends: Vec::new(),
2884                web: None,
2885            },
2886        ))
2887        .expect("project creation must succeed");
2888
2889        let assets = project.assets_dir();
2890        assert!(
2891            assets.is_dir(),
2892            "the assets root {} must exist after `water create`",
2893            assets.display()
2894        );
2895        assert_eq!(
2896            assets,
2897            root.join(project.assets_path()),
2898            "the scaffolded directory must be the one the manifest declares"
2899        );
2900        assert!(
2901            assets.join("README.md").is_file(),
2902            "a tracked file keeps the assets directory present in git"
2903        );
2904    }
2905
2906    /// `stable` withholds the git-pinned experimental scaffold packages, so a
2907    /// backend whose generated crate links one — GTK4, `WinUI`, Dew — must fail
2908    /// `create` before a file lands, naming the package and the channel fix
2909    /// rather than dying partway through the backend's own scaffold.
2910    #[test]
2911    fn create_rejects_backends_whose_packages_stable_withholds() {
2912        for backend in [
2913            TargetBackend::Gtk4,
2914            TargetBackend::WinUi,
2915            TargetBackend::Dew,
2916        ] {
2917            let dir = tempfile::tempdir().expect("temp dir");
2918            let root = dir.path().join("water-example");
2919            let error = smol::block_on(Project::create(
2920                &root,
2921                CreateOptions {
2922                    name: "Water Example".to_string(),
2923                    bundle_identifier: BundleIdentifier::try_from("dev.waterui.waterexample")
2924                        .expect("bundle identifier"),
2925                    package_type: PackageType::App,
2926                    waterui_path: None,
2927                    channel: None,
2928                    framework_manifest: None,
2929                    framework: Some(crate::framework::test_fixtures::stable_framework()),
2930                    author: "Lexo Liu".to_string(),
2931                    backends: vec![backend],
2932                    web: None,
2933                },
2934            ))
2935            .expect_err("a withheld scaffold package must reject create");
2936            let error = error.to_string();
2937            for package in backend.scaffold_packages() {
2938                assert!(error.contains(package), "{error}");
2939            }
2940            assert!(error.contains("stable"), "{error}");
2941            assert!(error.contains("--channel dev"), "{error}");
2942            assert!(
2943                !root.exists(),
2944                "the rejection precedes any file write: {error}"
2945            );
2946        }
2947    }
2948
2949    /// Generated crate names carry the project-root tag that keeps a shared
2950    /// Cargo target directory unambiguous; the names packaged binaries ship
2951    /// under drop it — a checkout path must never appear in a shipped
2952    /// executable name.
2953    #[test]
2954    fn shipped_binary_names_drop_the_project_root_tag() {
2955        let dir = tempfile::tempdir().expect("temp dir");
2956        let root = dir.path().join("water-example");
2957        let project = smol::block_on(Project::create(
2958            &root,
2959            CreateOptions {
2960                name: "Water Example".to_string(),
2961                bundle_identifier: BundleIdentifier::try_from("dev.waterui.waterexample")
2962                    .expect("bundle identifier"),
2963                package_type: PackageType::Playground,
2964                waterui_path: None,
2965                channel: None,
2966                framework_manifest: None,
2967                framework: Some(crate::framework::test_fixtures::stable_framework()),
2968                author: "Lexo Liu".to_string(),
2969                backends: Vec::new(),
2970                web: None,
2971            },
2972        ))
2973        .expect("project creation must succeed");
2974
2975        for (shipped, tagged) in [
2976            (project.gtk4_binary_name(), project.gtk_backend_crate_name()),
2977            (
2978                project.hydrolysis_binary_name(),
2979                project.hydrolysis_backend_crate_name(),
2980            ),
2981            (
2982                project.winui_binary_name(),
2983                project.winui_backend_crate_name(),
2984            ),
2985            (
2986                project.esp32_binary_name(),
2987                project.esp32_backend_crate_name(),
2988            ),
2989        ] {
2990            assert!(
2991                tagged.as_str().starts_with(&format!("{shipped}-")),
2992                "the build name must be the shipped name plus the tag: {tagged}"
2993            );
2994            assert_eq!(
2995                tagged.as_str().len() - shipped.as_str().len(),
2996                9,
2997                "the tag is a dash plus eight hex digits: {tagged}"
2998            );
2999        }
3000    }
3001
3002    /// Packaged executables stage under the project's own managed backend
3003    /// directory — `dist/<platform>/<profile>` below `backend_path` — so
3004    /// two projects sharing a crate name, most often two worktrees of one
3005    /// project, never write the same shipped path the way the shared Cargo
3006    /// profile directory made them.
3007    #[test]
3008    fn same_named_projects_stage_packaged_binaries_under_their_own_backends() {
3009        let dir = tempfile::tempdir().expect("temp dir");
3010        let create = |root: &Path| {
3011            smol::block_on(Project::create(
3012                root,
3013                CreateOptions {
3014                    name: "Water Example".to_string(),
3015                    bundle_identifier: BundleIdentifier::try_from("dev.waterui.waterexample")
3016                        .expect("bundle identifier"),
3017                    package_type: PackageType::App,
3018                    waterui_path: None,
3019                    channel: None,
3020                    framework_manifest: None,
3021                    framework: Some(crate::framework::test_fixtures::stable_framework()),
3022                    author: "Lexo Liu".to_string(),
3023                    backends: Vec::new(),
3024                    web: None,
3025                },
3026            ))
3027            .expect("project creation must succeed")
3028        };
3029        let first = create(&dir.path().join("one/demo"));
3030        let second = create(&dir.path().join("two/demo"));
3031
3032        let staged = |project: &Project| {
3033            crate::platforming::packaging::dist_dir(
3034                &project.backend_path::<crate::hydrolysis::backend::HydrolysisBackend>(),
3035                "linux",
3036                Some("release"),
3037            )
3038            .join(project.hydrolysis_binary_name().as_str())
3039        };
3040        let first_staged = staged(&first);
3041        let second_staged = staged(&second);
3042
3043        assert_ne!(
3044            first_staged, second_staged,
3045            "same-named projects must not stage the same shipped path"
3046        );
3047        for (project, staged) in [(&first, &first_staged), (&second, &second_staged)] {
3048            assert!(
3049                staged.starts_with(
3050                    project.backend_path::<crate::hydrolysis::backend::HydrolysisBackend>()
3051                ),
3052                "{} must live under the project's own managed backend directory",
3053                staged.display()
3054            );
3055        }
3056    }
3057}
3058
3059#[cfg(test)]
3060mod local_patch_tests {
3061    use std::path::Path;
3062
3063    use super::Project;
3064
3065    /// A project on a `waterui_path` mirrors the checkout's `[patch]` tables
3066    /// every time it opens: entries the checkout dropped disappear, moved ones
3067    /// follow, and a project already in line is left byte-for-byte alone.
3068    #[test]
3069    fn a_local_checkout_project_follows_the_checkouts_patch_tables() {
3070        let directory = tempfile::tempdir().expect("temp dir");
3071        let checkout = directory.path().join("waterui");
3072        std::fs::create_dir_all(&checkout).expect("checkout dir");
3073        std::fs::write(
3074            checkout.join("Cargo.toml"),
3075            include_str!("../../tests/fixtures/local_checkout_patches.toml"),
3076        )
3077        .expect("checkout manifest");
3078        let app = directory.path().join("app");
3079        std::fs::create_dir_all(&app).expect("project dir");
3080        let cargo_path = app.join("Cargo.toml");
3081        std::fs::write(
3082            &cargo_path,
3083            "[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",
3084        )
3085        .expect("project manifest");
3086
3087        smol::block_on(Project::refresh_local_patches(
3088            &app,
3089            Path::new("../waterui"),
3090        ))
3091        .expect("tables refresh");
3092        let refreshed = std::fs::read_to_string(&cargo_path).expect("refreshed manifest");
3093        let manifest = cargo_toml::Manifest::from_str(&refreshed).expect("manifest parses");
3094        let crates_io = &manifest.patch["crates-io"];
3095        let cargo_toml::Dependency::Detailed(core) = &crates_io["waterui-core"] else {
3096            panic!("the core patch is a path dependency");
3097        };
3098        assert_eq!(core.path.as_deref(), Some("../waterui/core"));
3099        assert!(!crates_io.contains_key("stale"));
3100        assert!(crates_io.contains_key("vello"));
3101        assert!(refreshed.starts_with("[package]"));
3102
3103        smol::block_on(Project::refresh_local_patches(
3104            &app,
3105            Path::new("../waterui"),
3106        ))
3107        .expect("second refresh");
3108        assert_eq!(
3109            std::fs::read_to_string(&cargo_path).expect("manifest after the second refresh"),
3110            refreshed
3111        );
3112    }
3113
3114    /// A project inside the checkout's own workspace — every example in this
3115    /// repository — is already governed by the checkout's tables, so nothing is
3116    /// copied into the member manifest, where Cargo would ignore it and warn on
3117    /// every build.
3118    #[test]
3119    fn a_member_of_the_checkouts_workspace_keeps_its_manifest() {
3120        let directory = tempfile::tempdir().expect("temp dir");
3121        let checkout = directory.path().join("waterui");
3122        let app = checkout.join("examples/app");
3123        std::fs::create_dir_all(&app).expect("project dir");
3124        std::fs::write(
3125            checkout.join("Cargo.toml"),
3126            include_str!("../../tests/fixtures/local_checkout_patches.toml"),
3127        )
3128        .expect("checkout manifest");
3129        let manifest = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nwaterui = { path = \"../..\" }\n";
3130        let cargo_path = app.join("Cargo.toml");
3131        std::fs::write(&cargo_path, manifest).expect("project manifest");
3132
3133        smol::block_on(Project::refresh_local_patches(&app, Path::new("../..")))
3134            .expect("tables refresh");
3135
3136        assert_eq!(
3137            std::fs::read_to_string(&cargo_path).expect("manifest after the refresh"),
3138            manifest
3139        );
3140    }
3141
3142    /// A project inside someone else's workspace cannot carry the tables at all:
3143    /// Cargo reads them from that workspace root. Saying which manifest they
3144    /// belong in beats writing a copy that is read by nobody.
3145    #[test]
3146    fn a_member_of_a_foreign_workspace_is_told_where_the_tables_belong() {
3147        let directory = tempfile::tempdir().expect("temp dir");
3148        let checkout = directory.path().join("waterui");
3149        std::fs::create_dir_all(&checkout).expect("checkout dir");
3150        std::fs::write(
3151            checkout.join("Cargo.toml"),
3152            include_str!("../../tests/fixtures/local_checkout_patches.toml"),
3153        )
3154        .expect("checkout manifest");
3155        let workspace = directory.path().join("their-workspace");
3156        let app = workspace.join("app");
3157        std::fs::create_dir_all(&app).expect("project dir");
3158        std::fs::write(
3159            workspace.join("Cargo.toml"),
3160            "[workspace]\nmembers = [\"app\"]\n",
3161        )
3162        .expect("workspace manifest");
3163        let manifest = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nwaterui = { path = \"../../waterui\" }\n";
3164        let cargo_path = app.join("Cargo.toml");
3165        std::fs::write(&cargo_path, manifest).expect("project manifest");
3166
3167        let error = smol::block_on(Project::refresh_local_patches(
3168            &app,
3169            Path::new("../../waterui"),
3170        ))
3171        .expect_err("a copy here would be ignored");
3172
3173        let message = error.to_string();
3174        assert!(message.contains("their-workspace"), "{message}");
3175        assert_eq!(
3176            std::fs::read_to_string(&cargo_path).expect("manifest after the refusal"),
3177            manifest
3178        );
3179    }
3180}