waterui_cli/
templates.rs

1//! Type-safe template scaffolding for `WaterUI` project backends.
2//!
3//! Uses `include_dir` to embed templates at compile time and provides
4//! a type-safe substitution API for generating Apple and Android backend projects.
5
6use std::{
7    io,
8    path::{Path, PathBuf},
9};
10
11const WATERUI_VERSION: &str = "0.2";
12const WATERUI_FFI_VERSION: &str = "0.2";
13
14use include_dir::{Dir, include_dir};
15use smol::fs;
16
17/// Normalize a path to use forward slashes for config files (Cargo.toml, Xcode projects, etc.)
18/// This is necessary because Windows uses backslashes but these config files expect forward slashes.
19fn normalize_path_for_config(path: &Path) -> String {
20    path.to_string_lossy().replace('\\', "/")
21}
22
23/// Embedded template directories.
24mod embedded {
25    use super::{Dir, include_dir};
26
27    pub static APPLE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/templates/apple");
28    pub static ANDROID: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/templates/android");
29    pub static ROOT: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/templates");
30}
31
32/// Context for rendering templates with type-safe substitutions.
33#[derive(Debug, Clone)]
34pub struct TemplateContext {
35    /// The application display name (e.g., "My App")
36    pub app_display_name: String,
37    /// The application name for file/folder naming (e.g., "`MyApp`")
38    pub app_name: String,
39    /// The Rust crate name (e.g., "`my_app`")
40    pub crate_name: String,
41    /// The bundle identifier (e.g., "com.example.myapp")
42    pub bundle_identifier: String,
43    /// The author name
44    pub author: String,
45    /// Path to the Android backend (relative or absolute)
46    pub android_backend_path: Option<PathBuf>,
47    /// Whether to use remote dev backend (`JitPack`) instead of local
48    pub use_remote_dev_backend: bool,
49    /// Path to local `WaterUI` repository (for dev mode)
50    pub waterui_path: Option<PathBuf>,
51    /// Relative path from project root to where the Xcode/Android project is located.
52    /// Used to compute correct relative paths. Defaults to "apple" for standard projects.
53    /// For playground projects, this would be ".water/apple".
54    pub backend_project_path: Option<PathBuf>,
55    /// Android permissions to include in the manifest (e.g., "internet", "camera")
56    pub android_permissions: Vec<String>,
57}
58
59impl TemplateContext {
60    /// Render a template string by replacing all placeholders.
61    #[must_use]
62    pub fn render(&self, template: &str) -> String {
63        // Android namespace must be a valid Java package name (no hyphens)
64        let android_namespace = self.bundle_identifier.replace('-', "_");
65
66        template
67            .replace("__APP_DISPLAY_NAME__", &self.app_display_name)
68            .replace("__APP_NAME__", &self.app_name)
69            .replace("__CRATE_NAME__", &self.crate_name)
70            .replace("__ANDROID_NAMESPACE__", &android_namespace)
71            .replace("__BUNDLE_IDENTIFIER__", &self.bundle_identifier)
72            .replace("__AUTHOR__", &self.author)
73            .replace(
74                "__ANDROID_BACKEND_PATH__",
75                &self.compute_android_backend_path().unwrap_or_default(),
76            )
77            .replace(
78                "__USE_REMOTE_DEV_BACKEND__",
79                if self.use_remote_dev_backend {
80                    "true"
81                } else {
82                    "false"
83                },
84            )
85            .replace(
86                "__SWIFT_PACKAGE_REFERENCE_ENTRY__",
87                &self.swift_package_reference_entry(),
88            )
89            .replace(
90                "__SWIFT_PACKAGE_REFERENCE_SECTION__",
91                &self.swift_package_reference_section(),
92            )
93            .replace("__IOS_PERMISSION_KEYS__", "")
94            .replace("__ANDROID_PERMISSIONS__", &self.android_permissions_xml())
95            .replace(
96                "__PROJECT_ROOT_RELATIVE_PATH__",
97                &self.project_root_relative_path(),
98            )
99    }
100
101    /// Transform a path by replacing "`AppName`" with the actual app name.
102    #[must_use]
103    pub fn transform_path(&self, path: &Path) -> PathBuf {
104        let path_str = path.to_string_lossy();
105        PathBuf::from(path_str.replace("AppName", &self.app_name))
106    }
107
108    /// Compute the relative path from the backend project to a `WaterUI` backend.
109    ///
110    /// This accounts for the project being in a subdirectory (e.g., `.water/android`).
111    fn compute_relative_backend_path(&self, backend_subdir: &str) -> Option<String> {
112        let waterui_path = self.waterui_path.as_ref()?;
113
114        // If `waterui_path` is absolute, use it directly. This avoids producing invalid
115        // paths like `../../../..//Users/...` in generated config files.
116        if waterui_path.is_absolute() {
117            let absolute_backend_path = waterui_path.join("backends").join(backend_subdir);
118            return Some(normalize_path_for_config(&absolute_backend_path));
119        }
120
121        // Count how many levels deep the project is from the project root
122        // Default is 1 level (e.g., "android"), playground uses 2 levels (e.g., ".water/android")
123        let project_depth = self
124            .backend_project_path
125            .as_ref()
126            .map_or(1, |p| p.components().count());
127
128        // Build the relative path: go up `project_depth` levels, then to waterui_path/backends/<backend>.
129        // Use `PathBuf` joins to avoid accidental `//` sequences and to keep behavior consistent
130        // across platforms.
131        let mut backend_path = PathBuf::new();
132        for _ in 0..project_depth {
133            backend_path.push("..");
134        }
135        backend_path.push(waterui_path);
136        backend_path.push("backends");
137        backend_path.push(backend_subdir);
138
139        Some(normalize_path_for_config(&backend_path))
140    }
141
142    /// Compute the relative path from the Xcode project to the `WaterUI` Swift backend.
143    fn compute_apple_backend_path(&self) -> Option<String> {
144        self.compute_relative_backend_path("apple")
145    }
146
147    /// Compute the relative path from the Android project to the `WaterUI` Android backend.
148    fn compute_android_backend_path(&self) -> Option<String> {
149        self.compute_relative_backend_path("android")
150    }
151
152    /// Compute the relative path from the backend project directory to the project root.
153    ///
154    /// For a backend at `apple/`, returns `..` (go up 1 level).
155    /// For a backend at `.water/apple/`, returns `../..` (go up 2 levels).
156    fn project_root_relative_path(&self) -> String {
157        let depth = self
158            .backend_project_path
159            .as_ref()
160            .map_or(1, |p| p.components().count());
161
162        (0..depth).map(|_| "..").collect::<Vec<_>>().join("/")
163    }
164
165    /// Generate Android permission XML entries for the manifest.
166    fn android_permissions_xml(&self) -> String {
167        if self.android_permissions.is_empty() {
168            return String::new();
169        }
170
171        self.android_permissions
172            .iter()
173            .map(|perm| {
174                let android_perm = match perm.to_lowercase().as_str() {
175                    "internet" => "android.permission.INTERNET",
176                    "camera" => "android.permission.CAMERA",
177                    "microphone" => "android.permission.RECORD_AUDIO",
178                    "location" => "android.permission.ACCESS_FINE_LOCATION",
179                    "coarse_location" => "android.permission.ACCESS_COARSE_LOCATION",
180                    "storage" => "android.permission.READ_EXTERNAL_STORAGE",
181                    "write_storage" => "android.permission.WRITE_EXTERNAL_STORAGE",
182                    "bluetooth" => "android.permission.BLUETOOTH",
183                    "bluetooth_admin" => "android.permission.BLUETOOTH_ADMIN",
184                    "vibrate" => "android.permission.VIBRATE",
185                    "wake_lock" => "android.permission.WAKE_LOCK",
186                    // Allow raw Android permission names
187                    other => return format!("    <uses-permission android:name=\"{other}\" />"),
188                };
189                format!("    <uses-permission android:name=\"{android_perm}\" />")
190            })
191            .collect::<Vec<_>>()
192            .join("\n")
193    }
194
195    /// Generate the `XCode` package reference entry line for the project file.
196    fn swift_package_reference_entry(&self) -> String {
197        const PACKAGE_ID: &str = "D01867782E6C82CA00802E96";
198        const INDENT: &str = "\t\t\t\t";
199
200        self.compute_apple_backend_path().map_or_else(
201            || {
202                format!(
203                    "{INDENT}{PACKAGE_ID} /* XCRemoteSwiftPackageReference \"apple-backend\" */,"
204                )
205            },
206            |backend_path| {
207                format!(
208                    "{INDENT}{PACKAGE_ID} /* XCLocalSwiftPackageReference \"{backend_path}\" */,"
209                )
210            },
211        )
212    }
213
214    /// Generate the `XCode` package reference section for the project file.
215    fn swift_package_reference_section(&self) -> String {
216        const PACKAGE_ID: &str = "D01867782E6C82CA00802E96";
217        const REPO_URL: &str = "https://github.com/water-rs/apple-backend.git";
218        const MIN_VERSION: &str = "0.2.0";
219
220        self.compute_apple_backend_path().map_or_else(
221            || {
222                format!(
223                    "/* Begin XCRemoteSwiftPackageReference section */\n\
224                    \t\t{PACKAGE_ID} /* XCRemoteSwiftPackageReference \"apple-backend\" */ = {{\n\
225                    \t\t\tisa = XCRemoteSwiftPackageReference;\n\
226                    \t\t\trepositoryURL = \"{REPO_URL}\";\n\
227                    \t\t\trequirement = {{\n\
228                    \t\t\t\tkind = upToNextMajorVersion;\n\
229                    \t\t\t\tminimumVersion = {MIN_VERSION};\n\
230                    \t\t\t}};\n\
231                    \t\t}};\n\
232                    /* End XCRemoteSwiftPackageReference section */"
233                )
234            },
235            |backend_path| {
236                format!(
237                    "/* Begin XCLocalSwiftPackageReference section */\n\
238                    \t\t{PACKAGE_ID} /* XCLocalSwiftPackageReference \"{backend_path}\" */ = {{\n\
239                    \t\t\tisa = XCLocalSwiftPackageReference;\n\
240                    \t\t\trelativePath = \"{backend_path}\";\n\
241                    \t\t}};\n\
242                    /* End XCLocalSwiftPackageReference section */"
243                )
244            },
245        )
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::TemplateContext;
252    use std::path::PathBuf;
253
254    fn ctx(
255        waterui_path: Option<PathBuf>,
256        backend_project_path: Option<PathBuf>,
257    ) -> TemplateContext {
258        TemplateContext {
259            app_display_name: String::new(),
260            app_name: String::new(),
261            crate_name: String::new(),
262            bundle_identifier: "com.example.test".to_string(),
263            author: String::new(),
264            android_backend_path: None,
265            use_remote_dev_backend: waterui_path.is_none(),
266            waterui_path,
267            backend_project_path,
268            android_permissions: Vec::new(),
269        }
270    }
271
272    #[test]
273    fn relative_waterui_path_produces_clean_relative_backend_path() {
274        let ctx = ctx(
275            Some(PathBuf::from("../..")),
276            Some(PathBuf::from(".water/apple")),
277        );
278
279        let path = ctx
280            .compute_relative_backend_path("apple")
281            .expect("expected relative backend path");
282
283        assert_eq!(path, "../../../../backends/apple");
284        assert!(!path.contains("//"));
285    }
286
287    #[test]
288    fn absolute_waterui_path_is_used_directly() {
289        let abs = if cfg!(windows) {
290            PathBuf::from(r"C:\waterui")
291        } else {
292            PathBuf::from("/waterui")
293        };
294
295        let ctx = ctx(Some(abs), Some(PathBuf::from("apple")));
296        let path = ctx
297            .compute_relative_backend_path("apple")
298            .expect("expected backend path");
299
300        let expected = if cfg!(windows) {
301            "C:/waterui/backends/apple"
302        } else {
303            "/waterui/backends/apple"
304        };
305        assert_eq!(path, expected);
306    }
307}
308
309/// Scaffold a directory from embedded templates (non-recursive, uses stack).
310async fn scaffold_dir(
311    embedded_dir: &Dir<'_>,
312    base_dir: &Path,
313    ctx: &TemplateContext,
314) -> io::Result<()> {
315    // Use a stack to avoid async recursion (which requires boxing)
316    let mut dirs_to_process = vec![embedded_dir];
317
318    while let Some(current_dir) = dirs_to_process.pop() {
319        // Process all files in this directory
320        for file in current_dir.files() {
321            let relative_path = file.path();
322
323            // Determine if this is a template file and compute destination path
324            let is_template = relative_path
325                .extension()
326                .and_then(|ext| ext.to_str())
327                .is_some_and(|ext| ext == "tpl");
328
329            let dest_path = if is_template {
330                // Remove .tpl extension and transform path
331                let without_tpl = relative_path.with_extension("");
332                ctx.transform_path(&without_tpl)
333            } else {
334                // Binary file - just transform the path
335                ctx.transform_path(relative_path)
336            };
337
338            let full_dest = base_dir.join(&dest_path);
339
340            // Create parent directories
341            if let Some(parent) = full_dest.parent() {
342                fs::create_dir_all(parent).await?;
343            }
344
345            // Write file content
346            if is_template {
347                // Template file - render content
348                let content = file
349                    .contents_utf8()
350                    .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Invalid UTF-8"))?;
351                let rendered = ctx.render(content);
352                fs::write(&full_dest, rendered).await?;
353            } else {
354                // Binary file - copy as-is
355                fs::write(&full_dest, file.contents()).await?;
356            }
357        }
358
359        // Add subdirectories to the stack
360        for subdir in current_dir.dirs() {
361            dirs_to_process.push(subdir);
362        }
363    }
364
365    Ok(())
366}
367
368/// Apple backend templates.
369pub mod apple {
370    use super::{Path, TemplateContext, embedded, fs, io, scaffold_dir};
371
372    /// Write all Apple templates to the given directory.
373    ///
374    /// # Errors
375    ///
376    /// Returns an error if file operations fail.
377    pub async fn scaffold(base_dir: &Path, ctx: &TemplateContext) -> io::Result<()> {
378        scaffold_dir(&embedded::APPLE, base_dir, ctx).await?;
379
380        // Make build-rust.sh executable
381        #[cfg(unix)]
382        {
383            use std::os::unix::fs::PermissionsExt;
384            let script_path = base_dir.join("build-rust.sh");
385            if script_path.exists() {
386                let mut perms = fs::metadata(&script_path).await?.permissions();
387                perms.set_mode(0o755);
388                fs::set_permissions(&script_path, perms).await?;
389            }
390        }
391
392        Ok(())
393    }
394}
395
396/// Android backend templates.
397pub mod android {
398    use crate::android::toolchain::AndroidSdk;
399
400    use super::{Path, TemplateContext, embedded, fs, io, normalize_path_for_config, scaffold_dir};
401
402    /// Write all Android templates to the given directory.
403    ///
404    /// # Errors
405    /// Returns an error if file operations fail.
406    pub async fn scaffold(base_dir: &Path, ctx: &TemplateContext) -> io::Result<()> {
407        scaffold_dir(&embedded::ANDROID, base_dir, ctx).await?;
408
409        // Make gradlew executable
410        #[cfg(unix)]
411        {
412            use std::os::unix::fs::PermissionsExt;
413            let gradlew_path = base_dir.join("gradlew");
414            if gradlew_path.exists() {
415                let mut perms = fs::metadata(&gradlew_path).await?.permissions();
416                perms.set_mode(0o755);
417                fs::set_permissions(&gradlew_path, perms).await?;
418            }
419        }
420
421        // Create jniLibs directories
422        for abi in ["arm64-v8a", "x86_64", "armeabi-v7a", "x86"] {
423            let jni_dir = base_dir.join(format!("app/src/main/jniLibs/{abi}"));
424            fs::create_dir_all(&jni_dir).await?;
425        }
426
427        // Generate local.properties with Android SDK path
428        if let Some(sdk_path) = AndroidSdk::detect_path() {
429            let local_props = base_dir.join("local.properties");
430            let content = format!("sdk.dir={}\n", normalize_path_for_config(&sdk_path));
431            fs::write(&local_props, content).await?;
432        }
433
434        Ok(())
435    }
436}
437
438/// Root-level templates (Cargo.toml, lib.rs, .gitignore).
439pub mod root {
440    use crate::templates::{WATERUI_FFI_VERSION, WATERUI_VERSION};
441
442    use super::{Path, TemplateContext, embedded, fs, io, normalize_path_for_config};
443
444    /// Root template files (only .tpl files at the root level, excluding Cargo.toml).
445    static ROOT_TEMPLATES: &[&str] = &["lib.rs.tpl", ".gitignore.tpl"];
446
447    /// Write root templates to the given directory.
448    ///
449    /// # Errors
450    ///
451    /// Returns an error if file operations fail.
452    pub async fn scaffold(base_dir: &Path, ctx: &TemplateContext) -> io::Result<()> {
453        // Generate Cargo.toml programmatically using toml_edit
454        generate_cargo_toml(base_dir, ctx).await?;
455
456        // Process remaining templates
457        for template_name in ROOT_TEMPLATES {
458            if let Some(file) = embedded::ROOT.get_file(template_name) {
459                let dest_name = template_name.strip_suffix(".tpl").unwrap_or(template_name);
460                let dest_path = if dest_name == "lib.rs" {
461                    base_dir.join("src").join(dest_name)
462                } else {
463                    base_dir.join(dest_name)
464                };
465
466                // Create parent directories
467                if let Some(parent) = dest_path.parent() {
468                    fs::create_dir_all(parent).await?;
469                }
470
471                let content = file
472                    .contents_utf8()
473                    .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Invalid UTF-8"))?;
474                let rendered = ctx.render(content);
475                fs::write(&dest_path, rendered).await?;
476            }
477        }
478        Ok(())
479    }
480
481    /// Generate Cargo.toml programmatically using serde-compatible structs for type safety.
482    async fn generate_cargo_toml(base_dir: &Path, ctx: &TemplateContext) -> io::Result<()> {
483        use serde::Serialize;
484        use std::collections::BTreeMap;
485
486        #[derive(Serialize)]
487        struct CargoManifest {
488            package: PackageSection,
489            lib: LibSection,
490            dependencies: BTreeMap<String, DependencyValue>,
491            workspace: WorkspaceSection,
492        }
493
494        #[derive(Serialize)]
495        struct PackageSection {
496            name: String,
497            version: String,
498            edition: String,
499            authors: Vec<String>,
500        }
501
502        #[derive(Serialize)]
503        struct LibSection {
504            #[serde(rename = "crate-type")]
505            crate_type: Vec<String>,
506        }
507
508        #[derive(Serialize)]
509        struct WorkspaceSection {}
510
511        #[derive(Serialize)]
512        #[serde(untagged)]
513        enum DependencyValue {
514            Simple(String),
515            Detailed(DependencyDetail),
516        }
517
518        #[derive(Serialize)]
519        struct DependencyDetail {
520            path: String,
521        }
522
523        let mut dependencies = BTreeMap::new();
524
525        if let Some(waterui_path) = &ctx.waterui_path {
526            // Local path dependencies
527            dependencies.insert(
528                "waterui".to_string(),
529                DependencyValue::Detailed(DependencyDetail {
530                    path: normalize_path_for_config(waterui_path),
531                }),
532            );
533
534            let ffi_path = waterui_path.join("ffi");
535            dependencies.insert(
536                "waterui-ffi".to_string(),
537                DependencyValue::Detailed(DependencyDetail {
538                    path: normalize_path_for_config(&ffi_path),
539                }),
540            );
541        } else {
542            // Registry dependencies
543            dependencies.insert(
544                "waterui".to_string(),
545                DependencyValue::Simple(WATERUI_VERSION.to_string()),
546            );
547            dependencies.insert(
548                "waterui-ffi".to_string(),
549                DependencyValue::Simple(WATERUI_FFI_VERSION.to_string()),
550            );
551        }
552
553        let manifest = CargoManifest {
554            package: PackageSection {
555                name: ctx.crate_name.clone(),
556                version: "0.1.0".to_string(),
557                edition: "2024".to_string(),
558                authors: vec![ctx.author.clone()],
559            },
560            lib: LibSection {
561                crate_type: vec![
562                    "staticlib".to_string(),
563                    "cdylib".to_string(),
564                    "rlib".to_string(),
565                ],
566            },
567            dependencies,
568            workspace: WorkspaceSection {},
569        };
570
571        // Serialize to TOML
572        let toml_string = toml::to_string_pretty(&manifest)
573            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
574
575        let cargo_path = base_dir.join("Cargo.toml");
576        fs::write(&cargo_path, toml_string).await?;
577
578        Ok(())
579    }
580}