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