waterui_cli/
project.rs

1//! Project management and build utilities for `WaterUI` CLI.
2
3use cargo_toml::Manifest as CargoManifest;
4use color_eyre::eyre;
5use tracing::info;
6
7use crate::backend::Backend;
8
9/// Represents a `WaterUI` project with its manifest and crate information.
10#[derive(Debug, Clone)]
11pub struct Project {
12    root: PathBuf,
13    manifest: Manifest,
14    crate_name: String,
15    target_dir: PathBuf,
16}
17
18impl Project {
19    /// Run the `WaterUI` project on the specified device.
20    ///
21    /// This method handles building, packaging, and running the project.
22    ///
23    /// # Errors
24    /// - If any step in the build, package, or run process fails.
25    pub async fn run(&self, device: impl Device, hot_reload: bool) -> Result<Running, FailToRun> {
26        use crate::debug::hot_reload::{DEFAULT_PORT, HotReloadServer};
27
28        let platform = device.platform();
29
30        // Build rust library for the target platform
31        platform
32            .build(self, BuildOptions::new(false, hot_reload))
33            .await
34            .map_err(FailToRun::Build)?;
35
36        // Package the build artifacts for the target platform
37        let artifact = platform
38            .package(self, PackageOptions::new(false, true))
39            .await
40            .map_err(FailToRun::Package)?;
41
42        // Set up run options with hot reload environment variables if enabled
43        let mut run_options = RunOptions::new();
44
45        let server = if hot_reload {
46            // Start the hot reload server
47            let server = HotReloadServer::launch(DEFAULT_PORT)
48                .await
49                .map_err(FailToRun::HotReload)?;
50
51            // Set environment variables for the app to connect back
52            run_options.insert_env_var("WATERUI_HOT_RELOAD_HOST".to_string(), server.host());
53            run_options.insert_env_var(
54                "WATERUI_HOT_RELOAD_PORT".to_string(),
55                server.port().to_string(),
56            );
57
58            info!(
59                "Hot reload server started on {}:{}",
60                server.host(),
61                server.port()
62            );
63
64            Some(server)
65        } else {
66            None
67        };
68
69        info!("Running on device");
70
71        let mut running = device.run(artifact, run_options).await?;
72
73        if let Some(server) = server {
74            running.retain(server);
75        }
76
77        Ok(running)
78    }
79
80    /// Get the root path of the project.
81    ///
82    /// Same as the directory containing `Water.toml`.
83    #[must_use]
84    pub fn root(&self) -> &Path {
85        &self.root
86    }
87
88    /// Get the target directory for Rust build artifacts.
89    #[must_use]
90    pub fn target_dir(&self) -> &Path {
91        &self.target_dir
92    }
93
94    /// Get the backends configured for the project.
95    #[must_use]
96    pub const fn backends(&self) -> &Backends {
97        &self.manifest.backends
98    }
99
100    /// Get the crate name of the project.
101    #[must_use]
102    pub fn crate_name(&self) -> &str {
103        &self.crate_name
104    }
105
106    /// Get the Apple backend configuration if available.
107    #[must_use]
108    pub const fn apple_backend(&self) -> Option<&AppleBackend> {
109        self.manifest.backends.apple()
110    }
111
112    /// Get the full path to a backend directory.
113    ///
114    /// Returns `project.root() / backends.path / B::DEFAULT_PATH`.
115    #[must_use]
116    pub fn backend_path<B: Backend>(&self) -> PathBuf {
117        self.root
118            .join(self.manifest.backends.path())
119            .join(B::DEFAULT_PATH)
120    }
121
122    /// Get the relative path to a backend directory from project root.
123    ///
124    /// Returns `backends.path / B::DEFAULT_PATH`.
125    #[must_use]
126    pub fn backend_relative_path<B: Backend>(&self) -> PathBuf {
127        self.manifest.backends.path().join(B::DEFAULT_PATH)
128    }
129
130    /// Get the Android backend configuration if available.
131    #[must_use]
132    pub const fn android_backend(&self) -> Option<&AndroidBackend> {
133        self.manifest.backends.android()
134    }
135
136    /// Get the manifest of the project.
137    #[must_use]
138    pub const fn manifest(&self) -> &Manifest {
139        &self.manifest
140    }
141
142    /// Get the bundle identifier of the project.
143    #[must_use]
144    pub const fn bundle_identifier(&self) -> &str {
145        self.manifest.package.bundle_identifier.as_str()
146    }
147
148    /// Clean build artifacts for the project on the specified platform.
149    ///
150    /// # Errors
151    ///
152    /// Returns an error if cleaning fails.
153    pub async fn clean(&self, platform: impl Platform) -> Result<(), eyre::Report> {
154        // Parrelly clean rust build artifacts and platform specific build artifacts
155        platform.clean(self).await
156    }
157
158    /// Clean all build artifacts for the project.
159    ///
160    /// This cleans:
161    /// - Rust target directory
162    /// - Apple build artifacts (if backend configured)
163    /// - Android build artifacts (if backend configured)
164    ///
165    /// # Errors
166    ///
167    /// Returns an error if any cleaning operation fails.
168    pub async fn clean_all(&self) -> Result<(), eyre::Report> {
169        use crate::{
170            android::platform::AndroidPlatform, apple::platform::ApplePlatform, platform::Platform,
171        };
172
173        // Clean Rust target directory
174        let target_dir = self.root.join("target");
175        if target_dir.exists() {
176            smol::fs::remove_dir_all(&target_dir).await?;
177        }
178
179        // Clean Apple backend if configured
180        if self.apple_backend().is_some() {
181            // Use a default platform for cleaning - the actual platform doesn't matter
182            // since clean() operates on the project-level build artifacts
183            ApplePlatform::macos().clean(self).await?;
184        }
185
186        // Clean Android backend if configured
187        if self.android_backend().is_some() {
188            AndroidPlatform::arm64().clean(self).await?;
189        }
190
191        Ok(())
192    }
193
194    /// Package the project for the specified platform.
195    ///
196    /// # Errors
197    ///
198    /// Returns an error if packaging fails.
199    pub async fn package(
200        &self,
201        platform: impl Platform,
202        options: PackageOptions,
203    ) -> Result<Artifact, eyre::Report> {
204        platform.package(self, options).await
205    }
206}
207
208/// Errors that can occur when opening a `WaterUI` project.
209#[derive(Debug, thiserror::Error)]
210pub enum FailToOpenProject {
211    /// Failed to open the Water.toml manifest.
212    #[error("Failed to open project manifest: {0}")]
213    Manifest(FailToOpenManifest),
214    /// Failed to read the Cargo.toml file.
215    #[error("Failed to read Cargo.toml: {0}")]
216    CargoManifest(cargo_toml::Error),
217
218    /// Failed to get Cargo metadata.
219    #[error("Failed to get Cargo metadata: {0}")]
220    TargetDirError(#[from] cargo_metadata::Error),
221
222    /// Missing crate name in Cargo.toml.
223    #[error("Invalid Cargo.toml: missing crate name")]
224    MissingCrateName,
225
226    /// Project permissions are not allowed in non-playground projects.
227    #[error("Project permissions are not allowed in non-playground projects")]
228    PermissionsNotAllowedInNonPlayground,
229
230    /// Backends configuration is not allowed in playground manifests.
231    #[error("Backends configuration is not allowed in playground projects")]
232    BackendsNotAllowedInPlayground,
233
234    /// Failed to initialize backend for playground project.
235    #[error("Failed to initialize backend: {0}")]
236    BackendInit(#[from] crate::backend::FailToInitBackend),
237}
238
239/// Errors that can occur when creating a new `WaterUI` project.
240#[derive(Debug, thiserror::Error)]
241pub enum FailToCreateProject {
242    /// The project directory already exists.
243    #[error("Directory already exists: {0}")]
244    DirectoryExists(PathBuf),
245    /// Failed to create project directory.
246    #[error("Failed to create directory: {0}")]
247    CreateDir(std::io::Error),
248    /// Failed to scaffold project files.
249    #[error("Failed to scaffold project: {0}")]
250    Scaffold(std::io::Error),
251    /// Failed to save manifest.
252    #[error("Failed to save manifest: {0}")]
253    SaveManifest(#[from] FailToSaveManifest),
254
255    /// Failed to get Cargo metadata.
256    #[error("Failed to get Cargo metadata: {0}")]
257    TargetDirError(#[from] cargo_metadata::Error),
258
259    /// Failed to initialize git repository.
260    #[error("Failed to initialize git repository: {0}")]
261    GitInit(std::io::Error),
262}
263
264/// Options for creating a new `WaterUI` project.
265#[derive(Debug, Clone)]
266pub struct CreateOptions {
267    /// Application display name (e.g., "Water Example").
268    pub name: String,
269    /// Bundle identifier (e.g., "com.example.waterexample").
270    pub bundle_identifier: String,
271    /// Whether to create a playground project.
272    pub playground: bool,
273    /// Path to local `WaterUI` repository for development.
274    pub waterui_path: Option<PathBuf>,
275    /// Author name for Cargo.toml.
276    pub author: String,
277}
278
279impl Project {
280    /// Create a new `WaterUI` project at the specified path.
281    ///
282    /// This creates the project directory, scaffolds root files (Cargo.toml, src/lib.rs),
283    /// and saves the Water.toml manifest. Use `init_apple_backend()` and `init_android_backend()`
284    /// to scaffold platform backends after creation.
285    ///
286    /// # Errors
287    /// - `FailToCreateProject::DirectoryExists`: If the directory already exists.
288    /// - `FailToCreateProject::CreateDir`: If creating the directory fails.
289    /// - `FailToCreateProject::Scaffold`: If scaffolding files fails.
290    /// - `FailToCreateProject::SaveManifest`: If saving the manifest fails.
291    pub async fn create(
292        path: impl AsRef<Path>,
293        options: CreateOptions,
294    ) -> Result<Self, FailToCreateProject> {
295        let path = path.as_ref().to_path_buf();
296
297        // Check if directory already exists
298        if path.exists() {
299            return Err(FailToCreateProject::DirectoryExists(path));
300        }
301
302        // Create project directory
303        smol::fs::create_dir_all(&path)
304            .await
305            .map_err(FailToCreateProject::CreateDir)?;
306
307        // Derive crate name from display name
308        let crate_name = options
309            .name
310            .chars()
311            .map(|c| {
312                if c.is_alphanumeric() {
313                    c.to_ascii_lowercase()
314                } else {
315                    '_'
316                }
317            })
318            .collect::<String>();
319
320        // Build template context for root files
321        let ctx = TemplateContext {
322            app_display_name: options.name.clone(),
323            app_name: options.name.replace(' ', ""),
324            crate_name: crate_name.clone(),
325            bundle_identifier: options.bundle_identifier.clone(),
326            author: options.author.clone(),
327            android_backend_path: options
328                .waterui_path
329                .as_ref()
330                .map(|p| p.join("backends/android")),
331            use_remote_dev_backend: options.waterui_path.is_none(),
332            waterui_path: options.waterui_path.clone(),
333            backend_project_path: None, // Root files don't need this
334            android_permissions: Vec::new(),
335        };
336
337        // Scaffold root files (Cargo.toml, src/lib.rs, .gitignore)
338        templates::root::scaffold(&path, &ctx)
339            .await
340            .map_err(FailToCreateProject::Scaffold)?;
341
342        // Build manifest
343        let package_type = if options.playground {
344            PackageType::Playground
345        } else {
346            PackageType::App
347        };
348
349        let manifest = Manifest {
350            package: Package {
351                package_type,
352                name: options.name.clone(),
353                bundle_identifier: options.bundle_identifier.clone(),
354            },
355            backends: Backends::default(),
356            waterui_path: options
357                .waterui_path
358                .as_ref()
359                .map(|p| p.display().to_string()),
360            permissions: HashMap::default(),
361        };
362
363        // Save Water.toml
364        manifest.save(&path).await?;
365
366        // Initialize git repository if not already in one
367        Self::ensure_git_init(&path).await?;
368
369        let target_dir = get_target_dir(&path)
370            .await
371            .map_err(FailToCreateProject::TargetDirError)?;
372
373        Ok(Self {
374            root: path,
375            manifest,
376            crate_name,
377            target_dir,
378        })
379    }
380
381    /// Ensure the project is initialized with git.
382    ///
383    /// Checks if the project directory is already part of a git repository.
384    /// If not, initializes a new git repository.
385    async fn ensure_git_init(path: &Path) -> Result<(), FailToCreateProject> {
386        // Check if already in a git repository
387
388        let mut cmd = Command::new("git");
389
390        let is_in_git = command(&mut cmd)
391            .args(["rev-parse", "--git-dir"])
392            .current_dir(path)
393            .output()
394            .await
395            .map(|output| output.status.success())
396            .unwrap_or(false);
397
398        if !is_in_git {
399            // Initialize a new git repository
400            let mut cmd = Command::new("git");
401            command(&mut cmd)
402                .args(["init"])
403                .current_dir(path)
404                .status()
405                .await
406                .map_err(FailToCreateProject::GitInit)?;
407        }
408
409        Ok(())
410    }
411
412    /// Initialize the Apple backend for this project.
413    ///
414    /// This scaffolds the Apple backend files and updates the manifest.
415    ///
416    /// # Errors
417    /// Returns an error if scaffolding fails.
418    pub async fn init_apple_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
419        use crate::backend::Backend;
420
421        let backend = AppleBackend::init(self).await?;
422        self.manifest.backends.set_apple(backend);
423        self.manifest
424            .save(&self.root)
425            .await
426            .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
427        Ok(())
428    }
429
430    /// Initialize the Android backend for this project.
431    ///
432    /// This scaffolds the Android backend files and updates the manifest.
433    ///
434    /// # Errors
435    /// Returns an error if scaffolding fails.
436    pub async fn init_android_backend(&mut self) -> Result<(), crate::backend::FailToInitBackend> {
437        use crate::backend::Backend;
438
439        let backend = AndroidBackend::init(self).await?;
440        self.manifest.backends.set_android(backend);
441        self.manifest
442            .save(&self.root)
443            .await
444            .map_err(|e| crate::backend::FailToInitBackend::Io(std::io::Error::other(e)))?;
445        Ok(())
446    }
447
448    /// Open a `WaterUI` project located at the specified path.
449    ///
450    /// This loads both the `Water.toml` manifest and the `Cargo.toml` file.
451    /// For playground projects, backends are automatically initialized if not configured.
452    ///
453    /// # Errors
454    /// - `FailToOpenProject::Manifest`: If there was an error opening the `Water.toml` manifest.
455    /// - `FailToOpenProject::CargoManifest`: If there was an error reading the `Cargo.toml` file.
456    /// - `FailToOpenProject::MissingCrateName`: If the crate name is missing in `Cargo.toml`.
457    pub async fn open(path: impl AsRef<Path>) -> Result<Self, FailToOpenProject> {
458        use crate::backend::Backend;
459
460        let path = path.as_ref().to_path_buf();
461        let mut manifest = Manifest::open(path.join("Water.toml"))
462            .await
463            .map_err(FailToOpenProject::Manifest)?;
464
465        let cargo_path = path.join("Cargo.toml");
466
467        let cargo_manifest = unblock(move || CargoManifest::from_path(cargo_path))
468            .await
469            .map_err(FailToOpenProject::CargoManifest)?;
470        let crate_name = cargo_manifest
471            .package
472            .map(|p| p.name)
473            .ok_or(FailToOpenProject::MissingCrateName)?;
474
475        let is_playground = manifest.package.package_type == PackageType::Playground;
476
477        // Check that permissions are only set for playground projects
478        if !is_playground && !manifest.permissions.is_empty() {
479            return Err(FailToOpenProject::PermissionsNotAllowedInNonPlayground);
480        }
481
482        // Check that backends are not configured in playground manifests
483        if is_playground && !manifest.backends.is_empty() {
484            return Err(FailToOpenProject::BackendsNotAllowedInPlayground);
485        }
486
487        // For playground projects, backends are stored in .water directory
488        if is_playground {
489            manifest.backends.set_path(".water");
490        }
491
492        let target_dir = get_target_dir(&path)
493            .await
494            .map_err(FailToOpenProject::TargetDirError)?;
495
496        let mut project = Self {
497            root: path,
498            manifest,
499            crate_name,
500            target_dir,
501        };
502
503        // For playground projects, auto-initialize backends
504        // Always re-scaffold templates on each run to pick up manifest changes (e.g., permissions)
505        // Build cache (build/, .gradle/, DerivedData/) is preserved since scaffold only writes template files
506        //
507        // Skip backend initialization when:
508        // 1. Running inside Xcode's sandboxed build script phase (WATERUI_SKIP_RUST_BUILD=1)
509        // 2. Running inside any sandbox (sandbox-exec sets __XCODE_BUILT_PRODUCTS_DIR_PATHS or similar)
510        // 3. Xcode is the current build tool (ACTION env var is set by Xcode)
511        let skip_backend_init = std::env::var("WATERUI_SKIP_RUST_BUILD")
512            .map(|v| v == "1")
513            .unwrap_or(false)
514            || std::env::var("ACTION").is_ok() // Xcode sets this during builds
515            || std::env::var("XCODE_PRODUCT_BUILD_VERSION").is_ok();
516
517        if is_playground && !skip_backend_init {
518            // Apple backend - always re-scaffold to pick up manifest changes
519            let apple_backend = AppleBackend::init(&project)
520                .await
521                .map_err(FailToOpenProject::BackendInit)?;
522            project.manifest.backends.set_apple(apple_backend);
523
524            // Android backend - always re-scaffold to pick up manifest changes
525            let android_backend = AndroidBackend::init(&project)
526                .await
527                .map_err(FailToOpenProject::BackendInit)?;
528            project.manifest.backends.set_android(android_backend);
529        }
530
531        Ok(project)
532    }
533}
534
535async fn get_target_dir(current_dir: &Path) -> Result<PathBuf, cargo_metadata::Error> {
536    let current_dir = current_dir.to_path_buf();
537    let metadata = unblock(|| {
538        cargo_metadata::MetadataCommand::new()
539            .no_deps()
540            .current_dir(current_dir)
541            .exec()
542    })
543    .await?;
544
545    let target_dir = metadata.target_directory.as_std_path();
546
547    Ok(target_dir.to_path_buf())
548}
549
550use std::{
551    collections::HashMap,
552    path::{Path, PathBuf},
553};
554
555use serde::{Deserialize, Serialize};
556use smol::{fs::read_to_string, process::Command, unblock};
557
558use crate::{
559    android::backend::AndroidBackend,
560    apple::backend::AppleBackend,
561    backend::Backends,
562    build::BuildOptions,
563    device::{Artifact, Device, FailToRun, RunOptions, Running},
564    platform::{PackageOptions, Platform},
565    templates::{self, TemplateContext},
566    utils::command,
567};
568
569/// Configuration for a `WaterUI` project persisted to `Water.toml`.
570#[derive(Debug, Serialize, Deserialize, Clone)]
571pub struct Manifest {
572    /// Package information.
573    pub package: Package,
574    /// Backend configurations for various platforms.
575    #[serde(default, skip_serializing_if = "Backends::is_empty")]
576    pub backends: Backends,
577    /// Path to local `WaterUI` repository for dev mode.
578    /// When set, all backends will use this path instead of the published versions.
579    #[serde(skip_serializing_if = "Option::is_none")]
580    pub waterui_path: Option<String>,
581    /// Permission configuration for playground projects.
582    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
583    pub permissions: HashMap<String, PermissionEntry>,
584}
585
586/// Permission entry for playground projects.
587#[derive(Debug, Serialize, Deserialize, Clone)]
588pub struct PermissionEntry {
589    enable: bool,
590    /// Explain why this permission is needed.
591    description: String,
592}
593
594impl PermissionEntry {
595    /// Check if this permission is enabled.
596    #[must_use]
597    pub const fn is_enabled(&self) -> bool {
598        self.enable
599    }
600}
601
602/// Errors that can occur when opening a `Water.toml` manifest file.
603#[derive(Debug, thiserror::Error)]
604pub enum FailToOpenManifest {
605    /// Failed to read the manifest file from the filesystem.
606    #[error("Failed to read manifest file: {0}")]
607    ReadError(std::io::Error),
608    /// The manifest file is invalid or malformed.
609    #[error("Invalid manifest file: {0}")]
610    InvalidManifest(toml::de::Error),
611
612    /// The manifest file was not found at the specified path.
613    #[error("Manifest file not found at the specified path")]
614    NotFound,
615}
616
617/// Errors that can occur when saving a `Water.toml` manifest file.
618#[derive(Debug, thiserror::Error)]
619pub enum FailToSaveManifest {
620    /// Failed to serialize the manifest to TOML.
621    #[error("Failed to serialize manifest: {0}")]
622    Serialize(toml::ser::Error),
623    /// Failed to write the manifest file to disk.
624    #[error("Failed to write manifest file: {0}")]
625    Write(std::io::Error),
626}
627impl Manifest {
628    /// Open and parse a `Water.toml` manifest file from the specified path.
629    ///
630    /// # Errors
631    /// - `FailToOpenManifest::ReadError`: If there was an error reading the file.
632    /// - `FailToOpenManifest::InvalidManifest`: If the file contents are not valid TOML.
633    /// - `FailToOpenManifest::NotFound`: If the file does not exist at the specified path.
634    pub async fn open(path: impl AsRef<Path>) -> Result<Self, FailToOpenManifest> {
635        let path = path.as_ref();
636        let result = read_to_string(path).await;
637
638        match result {
639            Ok(c) => toml::from_str(&c).map_err(FailToOpenManifest::InvalidManifest),
640            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(FailToOpenManifest::NotFound),
641            Err(e) => Err(FailToOpenManifest::ReadError(e)),
642        }
643    }
644
645    /// Save the manifest to a `Water.toml` file at the specified directory.
646    ///
647    /// # Errors
648    /// - If there was an error serializing the manifest to TOML.
649    /// - If there was an error writing the file.
650    pub async fn save(&self, dir: impl AsRef<Path>) -> Result<(), FailToSaveManifest> {
651        let path = dir.as_ref().join("Water.toml");
652        let content = toml::to_string_pretty(self).map_err(FailToSaveManifest::Serialize)?;
653        smol::fs::write(&path, content)
654            .await
655            .map_err(FailToSaveManifest::Write)
656    }
657
658    /// Create a new `Manifest` with the specified package information.
659    #[must_use]
660    pub fn new(package: Package) -> Self {
661        Self {
662            package,
663            backends: Backends::default(),
664            waterui_path: None,
665            permissions: HashMap::default(),
666        }
667    }
668}
669
670/// `[package]` section in `Water.toml`.
671#[derive(Debug, Serialize, Deserialize, Clone)]
672pub struct Package {
673    /// Type of the package (e.g., "app").
674    #[serde(rename = "type")]
675    pub package_type: PackageType,
676    /// Human-readable name of the application (e.g., "Water Demo").
677    pub name: String,
678    /// Bundle identifier for the application (e.g., "com.example.waterdemo").
679    pub bundle_identifier: String,
680}
681
682/// Package type indicating what kind of project this is.
683#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq)]
684#[serde(rename_all = "lowercase")]
685pub enum PackageType {
686    /// A standalone application with platform-specific backends.
687    #[default]
688    App,
689    /// A playground project for quick experimentation.
690    /// Platform projects are created in a temporary directory.
691    Playground,
692}