Skip to main content

smbcloud_model/
frontend_app.rs

1use {
2    crate::{
3        ar_date_format,
4        project::{DeploymentMethod, DeploymentStatus},
5        runner::Runner,
6    },
7    chrono::{DateTime, Utc},
8    serde::{Deserialize, Serialize},
9    serde_repr::{Deserialize_repr, Serialize_repr},
10    std::fmt::Display,
11    tsync::tsync,
12};
13
14/// Whether this app is a web application or a Tauri cross-platform app.
15#[derive(Deserialize_repr, Serialize_repr, Debug, Clone, Copy, PartialEq, Eq, Default)]
16#[repr(u8)]
17#[tsync]
18pub enum AppType {
19    /// Web application (SPA, SSR, static site). All legacy Projects map here.
20    #[default]
21    Web = 0,
22    /// Cross-platform desktop/mobile application built with Tauri.
23    Tauri = 1,
24}
25
26impl Display for AppType {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            AppType::Web => write!(f, "Web"),
30            AppType::Tauri => write!(f, "Tauri"),
31        }
32    }
33}
34
35/// Whether a repo holds one deployable app or several (monorepo).
36#[derive(Deserialize_repr, Serialize_repr, Debug, Clone, Copy, PartialEq, Eq, Default)]
37#[repr(u8)]
38#[tsync]
39pub enum RepoKind {
40    #[default]
41    SingleApp = 0,
42    Monorepo = 1,
43}
44
45impl Display for RepoKind {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            RepoKind::SingleApp => write!(f, "Single-app repo"),
49            RepoKind::Monorepo => write!(f, "Monorepo"),
50        }
51    }
52}
53
54/// A deployable frontend application on the smbCloud platform.
55///
56/// A `FrontendApp` is the unit that actually ships.
57///
58/// - `Project` is the umbrella workspace
59/// - `DeployRepo` is the git repository or monorepo root
60/// - `FrontendApp` is the deployable app inside that repo
61///
62/// A `FrontendApp` belongs to a `Tenant`, is associated with an owner workspace,
63/// and may optionally point at a `DeployRepo` plus a repo-relative `source_path`
64/// for monorepo deployments.
65// Older API versions serialize the repo enums nested in a frontend_app payload
66// as Rails string keys ("monorepo", "git") instead of integers. Accept both.
67#[derive(Deserialize)]
68#[serde(untagged)]
69enum EnumWireValue {
70    Int(u8),
71    Str(String),
72}
73
74fn deserialize_repo_kind<'de, D>(deserializer: D) -> Result<RepoKind, D::Error>
75where
76    D: serde::Deserializer<'de>,
77{
78    match EnumWireValue::deserialize(deserializer)? {
79        EnumWireValue::Int(0) => Ok(RepoKind::SingleApp),
80        EnumWireValue::Int(1) => Ok(RepoKind::Monorepo),
81        EnumWireValue::Str(value) if value == "single_app" => Ok(RepoKind::SingleApp),
82        EnumWireValue::Str(value) if value == "monorepo" => Ok(RepoKind::Monorepo),
83        EnumWireValue::Int(other) => Err(serde::de::Error::custom(format!(
84            "unknown repo_kind: {other}"
85        ))),
86        EnumWireValue::Str(other) => Err(serde::de::Error::custom(format!(
87            "unknown repo_kind: {other}"
88        ))),
89    }
90}
91
92fn deserialize_runner<'de, D>(deserializer: D) -> Result<Runner, D::Error>
93where
94    D: serde::Deserializer<'de>,
95{
96    match EnumWireValue::deserialize(deserializer)? {
97        EnumWireValue::Int(0) => Ok(Runner::NodeJs),
98        EnumWireValue::Int(1) => Ok(Runner::Static),
99        EnumWireValue::Int(2) => Ok(Runner::Ruby),
100        EnumWireValue::Int(3) => Ok(Runner::Swift),
101        EnumWireValue::Int(4) => Ok(Runner::Rust),
102        EnumWireValue::Int(255) => Ok(Runner::Monorepo),
103        EnumWireValue::Str(value) => match value.as_str() {
104            "node_js" => Ok(Runner::NodeJs),
105            "static" => Ok(Runner::Static),
106            "ruby" => Ok(Runner::Ruby),
107            "swift" => Ok(Runner::Swift),
108            "rust" => Ok(Runner::Rust),
109            "monorepo" => Ok(Runner::Monorepo),
110            other => Err(serde::de::Error::custom(format!("unknown runner: {other}"))),
111        },
112        EnumWireValue::Int(other) => {
113            Err(serde::de::Error::custom(format!("unknown runner: {other}")))
114        }
115    }
116}
117
118fn deserialize_deployment_method<'de, D>(deserializer: D) -> Result<DeploymentMethod, D::Error>
119where
120    D: serde::Deserializer<'de>,
121{
122    match EnumWireValue::deserialize(deserializer)? {
123        EnumWireValue::Int(0) => Ok(DeploymentMethod::Git),
124        EnumWireValue::Int(1) => Ok(DeploymentMethod::Rsync),
125        EnumWireValue::Str(value) if value == "git" => Ok(DeploymentMethod::Git),
126        EnumWireValue::Str(value) if value == "rsync" => Ok(DeploymentMethod::Rsync),
127        EnumWireValue::Int(other) => Err(serde::de::Error::custom(format!(
128            "unknown deployment_method: {other}"
129        ))),
130        EnumWireValue::Str(other) => Err(serde::de::Error::custom(format!(
131            "unknown deployment_method: {other}"
132        ))),
133    }
134}
135
136#[derive(Deserialize, Serialize, Debug, Clone)]
137#[tsync]
138pub struct DeployRepo {
139    pub id: i64,
140    pub name: String,
141    pub repository: String,
142    pub root_path: String,
143    // The API serializes these enums as integers (serde_repr), matching Rails.
144    // Tolerate the legacy string keys still emitted by older deployments.
145    #[serde(default, deserialize_with = "deserialize_repo_kind")]
146    pub repo_kind: RepoKind,
147    #[serde(default, deserialize_with = "deserialize_runner")]
148    pub runner: Runner,
149    #[serde(default, deserialize_with = "deserialize_deployment_method")]
150    pub deployment_method: DeploymentMethod,
151    /// Embedded in the repo-create response. A single-app repo creates its
152    /// app automatically server-side; this carries it back to the client.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub frontend_apps: Option<Vec<FrontendApp>>,
155}
156
157/// Payload for creating a new DeployRepo via the API.
158#[derive(Serialize, Debug, Deserialize, Clone)]
159#[tsync]
160pub struct DeployRepoCreate {
161    pub project_id: i32,
162    pub name: String,
163    pub repository: String,
164    pub repo_kind: RepoKind,
165    pub runner: Runner,
166    #[serde(default)]
167    pub deployment_method: DeploymentMethod,
168}
169
170#[derive(Deserialize, Serialize, Debug, Clone)]
171#[tsync]
172pub struct FrontendApp {
173    pub id: String,
174    pub name: String,
175    pub app_type: AppType,
176    pub runner: Runner,
177    #[serde(default)]
178    pub deployment_method: DeploymentMethod,
179    pub project_id: i32,
180    pub tenant_id: i32,
181    pub repository: Option<String>,
182    pub description: Option<String>,
183    pub deploy_repo_id: Option<i64>,
184    pub source_path: Option<String>,
185    pub deploy_repo: Option<DeployRepo>,
186    pub project_ids: Vec<i32>,
187
188    // ── CLI-local deployment config fields ───────────────────────────────────
189    // These are not persisted in the database; they are stored in the local
190    // .smbcloud config file alongside the FrontendApp record.
191    /// Deployment kind, e.g. "vite-spa". Absent for server-side runners.
192    #[serde(default)]
193    pub kind: Option<String>,
194    /// Local source directory to build from, e.g. "frontend/my-app".
195    #[serde(default)]
196    pub source: Option<String>,
197    /// Build output directory relative to `source`, e.g. "dist".
198    #[serde(default)]
199    pub output: Option<String>,
200    /// Package manager to use for the build step, e.g. "pnpm".
201    #[serde(default)]
202    pub package_manager: Option<String>,
203    /// PM2 process name to restart after a nextjs-ssr deploy.
204    #[serde(default)]
205    pub pm2_app: Option<String>,
206    /// Path to a shared lib directory to rsync before deploying.
207    #[serde(default)]
208    pub shared_lib: Option<String>,
209    /// SSH command to run on the server after rsyncing the shared lib.
210    #[serde(default)]
211    pub compile_cmd: Option<String>,
212    /// Remote destination path on the server.
213    #[serde(default)]
214    pub path: Option<String>,
215    #[serde(default)]
216    pub remote_path: Option<String>,
217    #[serde(default)]
218    pub output_path: Option<String>,
219    #[serde(default)]
220    pub build_command: Option<String>,
221    #[serde(default)]
222    pub install_command: Option<String>,
223    #[serde(default)]
224    pub binary_name: Option<String>,
225    #[serde(default)]
226    pub build_target: Option<String>,
227    #[serde(default)]
228    pub port: Option<u16>,
229    #[serde(default)]
230    pub shared_lib_path: Option<String>,
231
232    pub created_at: DateTime<Utc>,
233    pub updated_at: DateTime<Utc>,
234}
235
236impl Display for FrontendApp {
237    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238        write!(
239            f,
240            "ID: {}, Name: {}, Type: {}",
241            self.id, self.name, self.app_type
242        )
243    }
244}
245
246/// Payload for creating a new FrontendApp via the API.
247#[derive(Serialize, Debug, Deserialize, Clone)]
248#[tsync]
249pub struct FrontendAppCreate {
250    pub name: String,
251    pub project_id: i32,
252    pub app_type: AppType,
253    pub runner: Runner,
254    #[serde(default)]
255    pub deployment_method: DeploymentMethod,
256    pub repository: Option<String>,
257    pub description: Option<String>,
258    /// Repo this app deploys from, when the workspace tracks one.
259    pub deploy_repo_id: Option<i64>,
260    /// Repo-relative app path for monorepo targets, e.g. "apps/web".
261    pub source_path: Option<String>,
262}
263
264/// A deployment record tied to a FrontendApp.
265#[derive(Deserialize, Serialize, Debug)]
266#[tsync]
267pub struct FrontendAppDeployment {
268    pub id: i32,
269    pub frontend_app_id: String,
270    pub commit_hash: String,
271    pub status: DeploymentStatus,
272    #[serde(with = "ar_date_format")]
273    pub created_at: DateTime<Utc>,
274    #[serde(with = "ar_date_format")]
275    pub updated_at: DateTime<Utc>,
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use serde_json::json;
282
283    #[test]
284    fn test_frontend_app_create_serialization() {
285        let create = FrontendAppCreate {
286            name: "my-app".to_owned(),
287            project_id: 1,
288            app_type: AppType::Web,
289            runner: Runner::NodeJs,
290            deployment_method: DeploymentMethod::Git,
291            repository: Some("my-repo".to_owned()),
292            description: None,
293            deploy_repo_id: Some(7),
294            source_path: Some("apps/web".to_owned()),
295        };
296        let value = serde_json::to_value(&create).unwrap();
297        assert_eq!(value["app_type"], json!(0));
298        assert_eq!(value["runner"], json!(0));
299        assert_eq!(value["deployment_method"], json!(0));
300        assert_eq!(value["deploy_repo_id"], json!(7));
301        assert_eq!(value["source_path"], json!("apps/web"));
302    }
303
304    #[test]
305    fn test_deploy_repo_create_serialization() {
306        let create = DeployRepoCreate {
307            project_id: 1,
308            name: "my-repo".to_owned(),
309            repository: "my-repo".to_owned(),
310            repo_kind: RepoKind::Monorepo,
311            runner: Runner::Monorepo,
312            deployment_method: DeploymentMethod::Git,
313        };
314        let value = serde_json::to_value(&create).unwrap();
315        assert_eq!(value["repo_kind"], json!(1));
316        assert_eq!(value["runner"], json!(255));
317        assert_eq!(value["deployment_method"], json!(0));
318    }
319
320    #[test]
321    fn test_app_type_display() {
322        assert_eq!(AppType::Web.to_string(), "Web");
323        assert_eq!(AppType::Tauri.to_string(), "Tauri");
324    }
325
326    #[test]
327    fn test_deploy_repo_deserializes_integer_enums() {
328        let deploy_repo: DeployRepo = serde_json::from_value(json!({
329            "id": 5,
330            "name": "my-repo",
331            "repository": "my-repo",
332            "root_path": ".",
333            "repo_kind": 1,
334            "runner": 255,
335            "deployment_method": 0
336        }))
337        .unwrap();
338        assert_eq!(deploy_repo.repo_kind, RepoKind::Monorepo);
339        assert_eq!(deploy_repo.runner, Runner::Monorepo);
340        assert_eq!(deploy_repo.deployment_method, DeploymentMethod::Git);
341    }
342
343    #[test]
344    fn test_deploy_repo_deserializes_legacy_string_enums() {
345        let deploy_repo: DeployRepo = serde_json::from_value(json!({
346            "id": 5,
347            "name": "my-repo",
348            "repository": "my-repo",
349            "root_path": ".",
350            "repo_kind": "monorepo",
351            "runner": "monorepo",
352            "deployment_method": "git"
353        }))
354        .unwrap();
355        assert_eq!(deploy_repo.repo_kind, RepoKind::Monorepo);
356        assert_eq!(deploy_repo.runner, Runner::Monorepo);
357        assert_eq!(deploy_repo.deployment_method, DeploymentMethod::Git);
358    }
359}