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}