Skip to main content

waterui_cli/platforming/
backend.rs

1//! Backend configuration and initialization for `WaterUI` projects.
2
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7use crate::{
8    android::backend::AndroidBackend,
9    apple::backend::AppleBackend,
10    build::BuildOptions,
11    device::Artifact,
12    esp32::backend::Esp32Backend,
13    gtk4::backend::Gtk4Backend,
14    hydrolysis::backend::HydrolysisBackend,
15    platform::{PackageOptions, TargetPlatform},
16    project::Project,
17};
18
19/// Configuration for all backends in a `WaterUI` project.
20///
21/// `[backend]` in `Water.toml`
22#[derive(Debug, Serialize, Deserialize, Clone, Default)]
23pub struct Backends {
24    /// Base path for all backends, relative to project root.
25    /// Empty string means project root for app manifests.
26    /// Playground projects do not persist managed backend paths in `Water.toml`.
27    #[serde(default, skip_serializing_if = "String::is_empty")]
28    path: String,
29    android: Option<AndroidBackend>,
30    apple: Option<AppleBackend>,
31    gtk4: Option<Gtk4Backend>,
32    hydrolysis: Option<HydrolysisBackend>,
33    winui: Option<crate::winui::backend::WinUiBackend>,
34    esp32: Option<Esp32Backend>,
35}
36
37impl Backends {
38    /// Check if no backends are configured.
39    #[must_use]
40    pub const fn is_empty(&self) -> bool {
41        self.android.is_none()
42            && self.apple.is_none()
43            && self.gtk4.is_none()
44            && self.hydrolysis.is_none()
45            && self.winui.is_none()
46            && self.esp32.is_none()
47    }
48
49    #[cfg(test)]
50    pub(crate) fn set_esp32_for_tests(&mut self, backend: Esp32Backend) {
51        self.esp32 = Some(backend);
52    }
53
54    /// Whether any backend-project scaffolding is configured.
55    ///
56    /// `[backends.esp32]` is deliberately excluded: it carries device
57    /// configuration — chip, panel geometry, bundled fonts — that only the
58    /// app author can know, while the other entries describe backend
59    /// projects that playground mode delegates to the CLI. A `backend_path`
60    /// entry is excluded for the opposite reason: it only selects where the
61    /// CLI finds a backend's runtime source and configures no project.
62    #[must_use]
63    pub fn configures_backend_projects(&self) -> bool {
64        self.android
65            .as_ref()
66            .is_some_and(AndroidBackend::configures_project)
67            || self
68                .apple
69                .as_ref()
70                .is_some_and(AppleBackend::configures_project)
71            || self.gtk4.is_some()
72            || self.hydrolysis.is_some()
73            || self.winui.is_some()
74    }
75
76    /// Get the base path for backends, relative to project root.
77    #[must_use]
78    pub fn path(&self) -> &Path {
79        Path::new(&self.path)
80    }
81
82    /// Set the base path for backends.
83    pub fn set_path(&mut self, path: impl Into<String>) {
84        self.path = path.into();
85    }
86
87    /// Get the Android backend configuration, if any.
88    #[must_use]
89    pub const fn android(&self) -> Option<&AndroidBackend> {
90        self.android.as_ref()
91    }
92
93    /// Get the Apple backend configuration, if any.
94    #[must_use]
95    pub const fn apple(&self) -> Option<&AppleBackend> {
96        self.apple.as_ref()
97    }
98
99    /// Set the Apple backend configuration.
100    pub fn set_apple(&mut self, backend: AppleBackend) {
101        self.apple = Some(backend);
102    }
103
104    /// Remove Apple backend configuration.
105    pub fn clear_apple(&mut self) {
106        self.apple = None;
107    }
108
109    /// Set the Android backend configuration.
110    pub fn set_android(&mut self, backend: AndroidBackend) {
111        self.android = Some(backend);
112    }
113
114    /// Remove Android backend configuration.
115    pub fn clear_android(&mut self) {
116        self.android = None;
117    }
118
119    /// Get the GTK4 backend configuration, if any.
120    #[must_use]
121    pub const fn gtk4(&self) -> Option<&Gtk4Backend> {
122        self.gtk4.as_ref()
123    }
124
125    /// Set the GTK4 backend configuration.
126    pub fn set_gtk4(&mut self, backend: Gtk4Backend) {
127        self.gtk4 = Some(backend);
128    }
129
130    /// Remove GTK4 backend configuration.
131    pub fn clear_gtk4(&mut self) {
132        self.gtk4 = None;
133    }
134
135    /// Get the hydrolysis backend configuration, if any.
136    #[must_use]
137    pub const fn hydrolysis(&self) -> Option<&HydrolysisBackend> {
138        self.hydrolysis.as_ref()
139    }
140
141    /// Set the hydrolysis backend configuration.
142    pub fn set_hydrolysis(&mut self, backend: HydrolysisBackend) {
143        self.hydrolysis = Some(backend);
144    }
145
146    /// Remove hydrolysis backend configuration.
147    pub fn clear_hydrolysis(&mut self) {
148        self.hydrolysis = None;
149    }
150
151    /// Get the `WinUI` backend configuration, if any.
152    #[must_use]
153    pub const fn winui(&self) -> Option<&crate::winui::backend::WinUiBackend> {
154        self.winui.as_ref()
155    }
156
157    /// Set the `WinUI` backend configuration.
158    pub fn set_winui(&mut self, backend: crate::winui::backend::WinUiBackend) {
159        self.winui = Some(backend);
160    }
161
162    /// Remove `WinUI` backend configuration.
163    pub fn clear_winui(&mut self) {
164        self.winui = None;
165    }
166
167    /// Get the ESP32 backend configuration, if any.
168    #[must_use]
169    pub const fn esp32(&self) -> Option<&Esp32Backend> {
170        self.esp32.as_ref()
171    }
172
173    /// Set the ESP32 backend configuration.
174    pub fn set_esp32(&mut self, backend: Esp32Backend) {
175        self.esp32 = Some(backend);
176    }
177
178    /// Remove ESP32 backend configuration.
179    pub fn clear_esp32(&mut self) {
180        self.esp32 = None;
181    }
182}
183
184/// Error type for failing to initialize a backend.
185#[derive(Debug, thiserror::Error)]
186pub enum FailToInitBackend {
187    /// I/O error while scaffolding templates.
188    #[error("Failed to write template files: {0}")]
189    Io(#[from] std::io::Error),
190    /// Invalid backend configuration prevented scaffolding (e.g. an
191    /// unsupported chip in `[backends.esp32]`).
192    #[error("Invalid backend configuration: {0}")]
193    Config(#[source] eyre::Error),
194}
195
196/// Trait for backends in a `WaterUI` project.
197///
198/// A backend handles building and packaging for specific platforms.
199/// Each backend knows:
200/// - Which platforms it supports
201/// - How to build Rust code for those platforms
202/// - How to package artifacts for distribution
203pub trait Backend: Sized + Send + Sync {
204    /// The default relative path for this backend (e.g., "android", "apple").
205    const DEFAULT_PATH: &'static str;
206
207    /// Paths relative to the backend directory that should be preserved during re-scaffolding.
208    ///
209    /// These typically contain build caches that are expensive to regenerate.
210    /// During `reinit_backend()`, only items NOT in this list are deleted before calling `init()`.
211    const CACHE_PATHS: &'static [&'static str];
212
213    /// Get the relative path for this backend instance.
214    ///
215    /// This is relative to `Backends::path()`.
216    fn path(&self) -> &Path;
217
218    /// Initialize the backend for the given project.
219    ///
220    /// Creates necessary files/folders for the backend at `project.backend_path::<Self>()`.
221    /// Returns the initialized backend configuration.
222    fn init(project: &Project) -> impl Future<Output = Result<Self, FailToInitBackend>> + Send;
223
224    // =========================================================================
225    // New methods for build/package (migrated from Platform trait)
226    // =========================================================================
227
228    /// Check if this backend supports the given platform.
229    fn supports(&self, platform: TargetPlatform) -> bool;
230
231    /// Build the Rust library for the target platform.
232    ///
233    /// Returns the target directory path where the built library is located.
234    fn build(
235        &self,
236        project: &Project,
237        platform: TargetPlatform,
238        options: BuildOptions,
239    ) -> impl Future<Output = eyre::Result<PathBuf>> + Send;
240
241    /// Package the project for the target platform.
242    ///
243    /// Returns the artifact (e.g., .app, .apk, binary).
244    fn package(
245        &self,
246        project: &Project,
247        platform: TargetPlatform,
248        options: PackageOptions,
249    ) -> impl Future<Output = eyre::Result<Artifact>> + Send;
250
251    /// Clean build artifacts for the platform.
252    fn clean(
253        &self,
254        project: &Project,
255        platform: TargetPlatform,
256    ) -> impl Future<Output = eyre::Result<()>> + Send;
257}
258
259/// Re-initialize a backend, preserving cache directories.
260///
261/// This function:
262/// 1. Identifies cache paths that should be preserved (from `Backend::CACHE_PATHS`)
263/// 2. Deletes all non-cache items in the backend directory
264/// 3. Calls `Backend::init()` to re-scaffold the backend
265///
266/// This allows template updates to be applied while keeping expensive build caches.
267///
268/// # Errors
269/// Returns an error if the backend directory cannot be read, cleaned, or re-initialized.
270pub async fn reinit_backend<B: Backend>(project: &Project) -> Result<B, FailToInitBackend> {
271    let backend_path = project.backend_path::<B>();
272
273    if backend_path.exists() {
274        // Get cache paths to preserve
275        let cache_paths: std::collections::HashSet<&str> = B::CACHE_PATHS.iter().copied().collect();
276
277        // Delete only non-cache items
278        let entries = std::fs::read_dir(&backend_path)?;
279        for entry in entries {
280            let entry = entry?;
281            let name = entry.file_name();
282            let name_str = name.to_string_lossy();
283
284            if !cache_paths.contains(&*name_str) {
285                let path = entry.path();
286                if path.is_dir() {
287                    std::fs::remove_dir_all(&path)?;
288                } else {
289                    std::fs::remove_file(&path)?;
290                }
291            }
292        }
293    }
294
295    // Re-scaffold templates (cache dirs untouched)
296    B::init(project).await
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    /// `[backends.esp32]` is device configuration, not backend-project
304    /// scaffolding, so it alone must not trip the playground restriction.
305    #[test]
306    fn esp32_device_config_is_not_backend_project_configuration() {
307        let mut backends = Backends::default();
308        assert!(!backends.configures_backend_projects());
309
310        backends.set_esp32_for_tests(Esp32Backend::new());
311        assert!(!backends.configures_backend_projects());
312        assert!(!backends.is_empty());
313
314        backends.set_gtk4(Gtk4Backend::default());
315        assert!(backends.configures_backend_projects());
316    }
317
318    /// `[backends.android] backend_path` selects where the runtime comes
319    /// from; it configures no backend project, so a playground manifest may
320    /// carry it. Project settings such as `project_path` still count.
321    #[test]
322    fn android_backend_path_alone_is_not_project_configuration() {
323        let mut backends = Backends::default();
324        backends.set_android(AndroidBackend::new().with_backend_path("/opt/android-backend"));
325        assert!(!backends.configures_backend_projects());
326
327        backends.set_android(
328            AndroidBackend::new()
329                .with_backend_path("/opt/android-backend")
330                .with_project_path("droid"),
331        );
332        assert!(backends.configures_backend_projects());
333    }
334
335    /// The same distinction on the manifest surface `Project::open` reads:
336    /// a playground `Water.toml` with only `[backends.android] backend_path`
337    /// passes the playground gate; one that configures the Android project
338    /// is rejected.
339    #[test]
340    fn playground_manifest_may_select_the_android_runtime_source() {
341        let manifest: crate::project::Manifest = toml::from_str(
342            r#"
343                [package]
344                type = "playground"
345                name = "Demo"
346                bundle_identifier = "dev.waterui.demo"
347
348                [backends.android]
349                backend_path = "/opt/android-backend"
350            "#,
351        )
352        .expect("manifest parses");
353        assert_eq!(
354            manifest.package.package_type,
355            crate::project::PackageType::Playground
356        );
357        assert!(!manifest.backends.configures_backend_projects());
358        assert_eq!(
359            manifest
360                .backends
361                .android()
362                .and_then(|b| b.backend_path().map(str::to_string)),
363            Some("/opt/android-backend".to_string())
364        );
365
366        let scaffolded: crate::project::Manifest = toml::from_str(
367            r#"
368                [package]
369                type = "playground"
370                name = "Demo"
371                bundle_identifier = "dev.waterui.demo"
372
373                [backends.android]
374                project_path = "droid"
375            "#,
376        )
377        .expect("manifest parses");
378        assert!(scaffolded.backends.configures_backend_projects());
379    }
380}