1 use {
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]
18 pub 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
26 impl 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]
39 pub enum RepoKind {
40 #[default]
41 SingleApp = 0,
42 Monorepo = 1,
43 }
44
45 impl 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)]
69 enum EnumWireValue {
70 Int(u8),
71 Str(String),
72 }
73
74 fn deserialize_repo_kind<'de, D>(deserializer: D) -> Result<RepoKind, D::Error>
75 where
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
92 fn deserialize_runner<'de, D>(deserializer: D) -> Result<Runner, D::Error>
93 where
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
118 fn deserialize_deployment_method<'de, D>(deserializer: D) -> Result<DeploymentMethod, D::Error>
119 where
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]
138 pub 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]
160 pub 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]
172 pub 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
236 impl 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]
249 pub 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]
267 pub 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)]
279 mod 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 }