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 /// GitHub App connection driving auto-deploy on push, when one exists.
156 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub github_connection: Option<crate::github::GithubConnection>,
158 }
159
160 /// Payload for creating a new DeployRepo via the API.
161 #[derive(Serialize, Debug, Deserialize, Clone)]
162 #[tsync]
163 pub struct DeployRepoCreate {
164 pub project_id: i32,
165 pub name: String,
166 pub repository: String,
167 pub repo_kind: RepoKind,
168 pub runner: Runner,
169 #[serde(default)]
170 pub deployment_method: DeploymentMethod,
171 }
172
173 #[derive(Deserialize, Serialize, Debug, Clone)]
174 #[tsync]
175 pub struct FrontendApp {
176 pub id: String,
177 pub name: String,
178 pub app_type: AppType,
179 pub runner: Runner,
180 #[serde(default)]
181 pub deployment_method: DeploymentMethod,
182 pub project_id: i32,
183 pub tenant_id: i32,
184 pub repository: Option<String>,
185 pub description: Option<String>,
186 pub deploy_repo_id: Option<i64>,
187 pub source_path: Option<String>,
188 pub deploy_repo: Option<DeployRepo>,
189 pub project_ids: Vec<i32>,
190
191 // ── CLI-local deployment config fields ───────────────────────────────────
192 // These are not persisted in the database; they are stored in the local
193 // .smbcloud config file alongside the FrontendApp record.
194 /// Deployment kind, e.g. "vite-spa". Absent for server-side runners.
195 #[serde(default)]
196 pub kind: Option<String>,
197 /// Local source directory to build from, e.g. "frontend/my-app".
198 #[serde(default)]
199 pub source: Option<String>,
200 /// Build output directory relative to `source`, e.g. "dist".
201 #[serde(default)]
202 pub output: Option<String>,
203 /// Package manager to use for the build step, e.g. "pnpm".
204 #[serde(default)]
205 pub package_manager: Option<String>,
206 /// PM2 process name to restart after a nextjs-ssr deploy.
207 #[serde(default)]
208 pub pm2_app: Option<String>,
209 /// Path to a shared lib directory to rsync before deploying.
210 #[serde(default)]
211 pub shared_lib: Option<String>,
212 /// SSH command to run on the server after rsyncing the shared lib.
213 #[serde(default)]
214 pub compile_cmd: Option<String>,
215 /// Remote destination path on the server.
216 #[serde(default)]
217 pub path: Option<String>,
218 #[serde(default)]
219 pub remote_path: Option<String>,
220 #[serde(default)]
221 pub output_path: Option<String>,
222 #[serde(default)]
223 pub build_command: Option<String>,
224 #[serde(default)]
225 pub install_command: Option<String>,
226 #[serde(default)]
227 pub binary_name: Option<String>,
228 #[serde(default)]
229 pub build_target: Option<String>,
230 #[serde(default)]
231 pub port: Option<u16>,
232 #[serde(default)]
233 pub shared_lib_path: Option<String>,
234
235 pub created_at: DateTime<Utc>,
236 pub updated_at: DateTime<Utc>,
237 }
238
239 impl Display for FrontendApp {
240 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241 write!(
242 f,
243 "ID: {}, Name: {}, Type: {}",
244 self.id, self.name, self.app_type
245 )
246 }
247 }
248
249 /// Payload for creating a new FrontendApp via the API.
250 #[derive(Serialize, Debug, Deserialize, Clone)]
251 #[tsync]
252 pub struct FrontendAppCreate {
253 pub name: String,
254 pub project_id: i32,
255 pub app_type: AppType,
256 pub runner: Runner,
257 #[serde(default)]
258 pub deployment_method: DeploymentMethod,
259 pub repository: Option<String>,
260 pub description: Option<String>,
261 /// Repo this app deploys from, when the workspace tracks one.
262 pub deploy_repo_id: Option<i64>,
263 /// Repo-relative app path for monorepo targets, e.g. "apps/web".
264 pub source_path: Option<String>,
265 }
266
267 /// A deployment record tied to a FrontendApp.
268 #[derive(Deserialize, Serialize, Debug)]
269 #[tsync]
270 pub struct FrontendAppDeployment {
271 pub id: i32,
272 pub frontend_app_id: String,
273 pub commit_hash: String,
274 pub status: DeploymentStatus,
275 #[serde(with = "ar_date_format")]
276 pub created_at: DateTime<Utc>,
277 #[serde(with = "ar_date_format")]
278 pub updated_at: DateTime<Utc>,
279 }
280
281 #[cfg(test)]
282 mod tests {
283 use super::*;
284 use serde_json::json;
285
286 #[test]
287 fn test_frontend_app_create_serialization() {
288 let create = FrontendAppCreate {
289 name: "my-app".to_owned(),
290 project_id: 1,
291 app_type: AppType::Web,
292 runner: Runner::NodeJs,
293 deployment_method: DeploymentMethod::Git,
294 repository: Some("my-repo".to_owned()),
295 description: None,
296 deploy_repo_id: Some(7),
297 source_path: Some("apps/web".to_owned()),
298 };
299 let value = serde_json::to_value(&create).unwrap();
300 assert_eq!(value["app_type"], json!(0));
301 assert_eq!(value["runner"], json!(0));
302 assert_eq!(value["deployment_method"], json!(0));
303 assert_eq!(value["deploy_repo_id"], json!(7));
304 assert_eq!(value["source_path"], json!("apps/web"));
305 }
306
307 #[test]
308 fn test_deploy_repo_create_serialization() {
309 let create = DeployRepoCreate {
310 project_id: 1,
311 name: "my-repo".to_owned(),
312 repository: "my-repo".to_owned(),
313 repo_kind: RepoKind::Monorepo,
314 runner: Runner::Monorepo,
315 deployment_method: DeploymentMethod::Git,
316 };
317 let value = serde_json::to_value(&create).unwrap();
318 assert_eq!(value["repo_kind"], json!(1));
319 assert_eq!(value["runner"], json!(255));
320 assert_eq!(value["deployment_method"], json!(0));
321 }
322
323 #[test]
324 fn test_app_type_display() {
325 assert_eq!(AppType::Web.to_string(), "Web");
326 assert_eq!(AppType::Tauri.to_string(), "Tauri");
327 }
328
329 #[test]
330 fn test_deploy_repo_deserializes_integer_enums() {
331 let deploy_repo: DeployRepo = serde_json::from_value(json!({
332 "id": 5,
333 "name": "my-repo",
334 "repository": "my-repo",
335 "root_path": ".",
336 "repo_kind": 1,
337 "runner": 255,
338 "deployment_method": 0
339 }))
340 .unwrap();
341 assert_eq!(deploy_repo.repo_kind, RepoKind::Monorepo);
342 assert_eq!(deploy_repo.runner, Runner::Monorepo);
343 assert_eq!(deploy_repo.deployment_method, DeploymentMethod::Git);
344 }
345
346 #[test]
347 fn test_deploy_repo_deserializes_embedded_github_connection() {
348 let deploy_repo: DeployRepo = serde_json::from_value(json!({
349 "id": 5,
350 "name": "my-repo",
351 "repository": "my-repo",
352 "root_path": ".",
353 "repo_kind": 0,
354 "runner": 0,
355 "deployment_method": 0,
356 "github_connection": {
357 "id": 1,
358 "deploy_repo_id": 5,
359 "github_installation_id": 42,
360 "github_repo_full_name": "octocat/hello-world",
361 "production_branch": "main",
362 "created_at": "2026-01-01T00:00:00Z",
363 "updated_at": "2026-01-01T00:00:00Z"
364 }
365 }))
366 .unwrap();
367 let connection = deploy_repo
368 .github_connection
369 .expect("github_connection should be present");
370 assert_eq!(connection.github_repo_full_name, "octocat/hello-world");
371 }
372
373 #[test]
374 fn test_deploy_repo_deserializes_legacy_string_enums() {
375 let deploy_repo: DeployRepo = serde_json::from_value(json!({
376 "id": 5,
377 "name": "my-repo",
378 "repository": "my-repo",
379 "root_path": ".",
380 "repo_kind": "monorepo",
381 "runner": "monorepo",
382 "deployment_method": "git"
383 }))
384 .unwrap();
385 assert_eq!(deploy_repo.repo_kind, RepoKind::Monorepo);
386 assert_eq!(deploy_repo.runner, Runner::Monorepo);
387 assert_eq!(deploy_repo.deployment_method, DeploymentMethod::Git);
388 }
389 }