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    esp32: Option<Esp32Backend>,
34}
35
36impl Backends {
37    /// Check if no backends are configured.
38    #[must_use]
39    pub const fn is_empty(&self) -> bool {
40        self.android.is_none()
41            && self.apple.is_none()
42            && self.gtk4.is_none()
43            && self.hydrolysis.is_none()
44            && self.esp32.is_none()
45    }
46
47    #[cfg(test)]
48    pub(crate) fn set_esp32_for_tests(&mut self, backend: Esp32Backend) {
49        self.esp32 = Some(backend);
50    }
51
52    /// Whether any backend-project scaffolding is configured.
53    ///
54    /// `[backends.esp32]` is deliberately excluded: it carries device
55    /// configuration — chip, panel geometry, bundled fonts — that only the
56    /// app author can know, while the other entries describe backend
57    /// projects that playground mode delegates to the CLI.
58    #[must_use]
59    pub const fn configures_backend_projects(&self) -> bool {
60        self.android.is_some()
61            || self.apple.is_some()
62            || self.gtk4.is_some()
63            || self.hydrolysis.is_some()
64    }
65
66    /// Get the base path for backends, relative to project root.
67    #[must_use]
68    pub fn path(&self) -> &Path {
69        Path::new(&self.path)
70    }
71
72    /// Set the base path for backends.
73    pub fn set_path(&mut self, path: impl Into<String>) {
74        self.path = path.into();
75    }
76
77    /// Get the Android backend configuration, if any.
78    #[must_use]
79    pub const fn android(&self) -> Option<&AndroidBackend> {
80        self.android.as_ref()
81    }
82
83    /// Get the Apple backend configuration, if any.
84    #[must_use]
85    pub const fn apple(&self) -> Option<&AppleBackend> {
86        self.apple.as_ref()
87    }
88
89    /// Set the Apple backend configuration.
90    pub fn set_apple(&mut self, backend: AppleBackend) {
91        self.apple = Some(backend);
92    }
93
94    /// Remove Apple backend configuration.
95    pub fn clear_apple(&mut self) {
96        self.apple = None;
97    }
98
99    /// Set the Android backend configuration.
100    pub fn set_android(&mut self, backend: AndroidBackend) {
101        self.android = Some(backend);
102    }
103
104    /// Remove Android backend configuration.
105    pub fn clear_android(&mut self) {
106        self.android = None;
107    }
108
109    /// Get the GTK4 backend configuration, if any.
110    #[must_use]
111    pub const fn gtk4(&self) -> Option<&Gtk4Backend> {
112        self.gtk4.as_ref()
113    }
114
115    /// Set the GTK4 backend configuration.
116    pub fn set_gtk4(&mut self, backend: Gtk4Backend) {
117        self.gtk4 = Some(backend);
118    }
119
120    /// Remove GTK4 backend configuration.
121    pub fn clear_gtk4(&mut self) {
122        self.gtk4 = None;
123    }
124
125    /// Get the hydrolysis backend configuration, if any.
126    #[must_use]
127    pub const fn hydrolysis(&self) -> Option<&HydrolysisBackend> {
128        self.hydrolysis.as_ref()
129    }
130
131    /// Set the hydrolysis backend configuration.
132    pub fn set_hydrolysis(&mut self, backend: HydrolysisBackend) {
133        self.hydrolysis = Some(backend);
134    }
135
136    /// Remove hydrolysis backend configuration.
137    pub fn clear_hydrolysis(&mut self) {
138        self.hydrolysis = None;
139    }
140
141    /// Get the ESP32 backend configuration, if any.
142    #[must_use]
143    pub const fn esp32(&self) -> Option<&Esp32Backend> {
144        self.esp32.as_ref()
145    }
146
147    /// Set the ESP32 backend configuration.
148    pub fn set_esp32(&mut self, backend: Esp32Backend) {
149        self.esp32 = Some(backend);
150    }
151
152    /// Remove ESP32 backend configuration.
153    pub fn clear_esp32(&mut self) {
154        self.esp32 = None;
155    }
156}
157
158/// Error type for failing to initialize a backend.
159#[derive(Debug, thiserror::Error)]
160pub enum FailToInitBackend {
161    /// I/O error while scaffolding templates.
162    #[error("Failed to write template files: {0}")]
163    Io(#[from] std::io::Error),
164    /// Invalid backend configuration prevented scaffolding (e.g. an
165    /// unsupported chip in `[backends.esp32]`).
166    #[error("Invalid backend configuration: {0}")]
167    Config(#[source] eyre::Error),
168}
169
170/// Trait for backends in a `WaterUI` project.
171///
172/// A backend handles building and packaging for specific platforms.
173/// Each backend knows:
174/// - Which platforms it supports
175/// - How to build Rust code for those platforms
176/// - How to package artifacts for distribution
177pub trait Backend: Sized + Send + Sync {
178    /// The default relative path for this backend (e.g., "android", "apple").
179    const DEFAULT_PATH: &'static str;
180
181    /// Paths relative to the backend directory that should be preserved during re-scaffolding.
182    ///
183    /// These typically contain build caches that are expensive to regenerate.
184    /// During `reinit_backend()`, only items NOT in this list are deleted before calling `init()`.
185    const CACHE_PATHS: &'static [&'static str];
186
187    /// Get the relative path for this backend instance.
188    ///
189    /// This is relative to `Backends::path()`.
190    fn path(&self) -> &Path;
191
192    /// Initialize the backend for the given project.
193    ///
194    /// Creates necessary files/folders for the backend at `project.backend_path::<Self>()`.
195    /// Returns the initialized backend configuration.
196    fn init(project: &Project) -> impl Future<Output = Result<Self, FailToInitBackend>> + Send;
197
198    // =========================================================================
199    // New methods for build/package (migrated from Platform trait)
200    // =========================================================================
201
202    /// Check if this backend supports the given platform.
203    fn supports(&self, platform: TargetPlatform) -> bool;
204
205    /// Build the Rust library for the target platform.
206    ///
207    /// Returns the target directory path where the built library is located.
208    fn build(
209        &self,
210        project: &Project,
211        platform: TargetPlatform,
212        options: BuildOptions,
213    ) -> impl Future<Output = eyre::Result<PathBuf>> + Send;
214
215    /// Package the project for the target platform.
216    ///
217    /// Returns the artifact (e.g., .app, .apk, binary).
218    fn package(
219        &self,
220        project: &Project,
221        platform: TargetPlatform,
222        options: PackageOptions,
223    ) -> impl Future<Output = eyre::Result<Artifact>> + Send;
224
225    /// Clean build artifacts for the platform.
226    fn clean(
227        &self,
228        project: &Project,
229        platform: TargetPlatform,
230    ) -> impl Future<Output = eyre::Result<()>> + Send;
231}
232
233/// Re-initialize a backend, preserving cache directories.
234///
235/// This function:
236/// 1. Identifies cache paths that should be preserved (from `Backend::CACHE_PATHS`)
237/// 2. Deletes all non-cache items in the backend directory
238/// 3. Calls `Backend::init()` to re-scaffold the backend
239///
240/// This allows template updates to be applied while keeping expensive build caches.
241///
242/// # Errors
243/// Returns an error if the backend directory cannot be read, cleaned, or re-initialized.
244pub async fn reinit_backend<B: Backend>(project: &Project) -> Result<B, FailToInitBackend> {
245    let backend_path = project.backend_path::<B>();
246
247    if backend_path.exists() {
248        // Get cache paths to preserve
249        let cache_paths: std::collections::HashSet<&str> = B::CACHE_PATHS.iter().copied().collect();
250
251        // Delete only non-cache items
252        let entries = std::fs::read_dir(&backend_path)?;
253        for entry in entries {
254            let entry = entry?;
255            let name = entry.file_name();
256            let name_str = name.to_string_lossy();
257
258            if !cache_paths.contains(&*name_str) {
259                let path = entry.path();
260                if path.is_dir() {
261                    std::fs::remove_dir_all(&path)?;
262                } else {
263                    std::fs::remove_file(&path)?;
264                }
265            }
266        }
267    }
268
269    // Re-scaffold templates (cache dirs untouched)
270    B::init(project).await
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    /// `[backends.esp32]` is device configuration, not backend-project
278    /// scaffolding, so it alone must not trip the playground restriction.
279    #[test]
280    fn esp32_device_config_is_not_backend_project_configuration() {
281        let mut backends = Backends::default();
282        assert!(!backends.configures_backend_projects());
283
284        backends.set_esp32_for_tests(Esp32Backend::new());
285        assert!(!backends.configures_backend_projects());
286        assert!(!backends.is_empty());
287
288        backends.set_gtk4(Gtk4Backend::default());
289        assert!(backends.configures_backend_projects());
290    }
291}