1use anyhow::{Context, Result};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::Path;
6use validator::Validate;
7
8use crate::api_client::TenantInfo;
9use crate::error::CloudError;
10
11#[derive(Debug)]
12pub struct NewCloudTenantParams {
13 pub id: String,
15 pub name: String,
16 pub app_id: Option<String>,
17 pub hostname: Option<String>,
18 pub region: Option<String>,
19 pub database_url: Option<String>,
20 pub internal_database_url: String,
21 pub external_db_access: bool,
22 pub sync_token: Option<String>,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
26#[serde(rename_all = "snake_case")]
27pub enum TenantType {
28 #[default]
29 Local,
30 Cloud,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
34pub struct StoredTenant {
35 #[validate(length(min = 1, message = "Tenant ID cannot be empty"))]
37 pub id: String,
38
39 #[validate(length(min = 1, message = "Tenant name cannot be empty"))]
40 pub name: String,
41
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub app_id: Option<String>,
44
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub hostname: Option<String>,
47
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub region: Option<String>,
50
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub database_url: Option<String>,
53
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub internal_database_url: Option<String>,
56
57 #[serde(default)]
58 pub tenant_type: TenantType,
59
60 #[serde(default)]
61 pub external_db_access: bool,
62
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub sync_token: Option<String>,
65
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub shared_container_db: Option<String>,
68}
69
70impl StoredTenant {
71 #[must_use]
72 pub fn new(id: String, name: String) -> Self {
73 Self {
74 id,
75 name,
76 app_id: None,
77 hostname: None,
78 region: None,
79 database_url: None,
80 internal_database_url: None,
81 tenant_type: TenantType::default(),
82 external_db_access: false,
83 sync_token: None,
84 shared_container_db: None,
85 }
86 }
87
88 #[must_use]
89 pub const fn new_local(id: String, name: String, database_url: String) -> Self {
90 Self {
91 id,
92 name,
93 app_id: None,
94 hostname: None,
95 region: None,
96 database_url: Some(database_url),
97 internal_database_url: None,
98 tenant_type: TenantType::Local,
99 external_db_access: false,
100 sync_token: None,
101 shared_container_db: None,
102 }
103 }
104
105 #[must_use]
106 pub const fn new_local_shared(
107 id: String,
108 name: String,
109 database_url: String,
110 shared_container_db: String,
111 ) -> Self {
112 Self {
113 id,
114 name,
115 app_id: None,
116 hostname: None,
117 region: None,
118 database_url: Some(database_url),
119 internal_database_url: None,
120 tenant_type: TenantType::Local,
121 external_db_access: false,
122 sync_token: None,
123 shared_container_db: Some(shared_container_db),
124 }
125 }
126
127 #[must_use]
128 pub fn new_cloud(params: NewCloudTenantParams) -> Self {
129 Self {
130 id: params.id,
131 name: params.name,
132 app_id: params.app_id,
133 hostname: params.hostname,
134 region: params.region,
135 database_url: params.database_url,
136 internal_database_url: Some(params.internal_database_url),
137 tenant_type: TenantType::Cloud,
138 external_db_access: params.external_db_access,
139 sync_token: params.sync_token,
140 shared_container_db: None,
141 }
142 }
143
144 #[must_use]
145 pub fn from_tenant_info(info: &TenantInfo) -> Self {
146 Self {
147 id: info.id.clone(),
148 name: info.name.clone(),
149 app_id: info.app_id.clone(),
150 hostname: info.hostname.clone(),
151 region: info.region.clone(),
152 database_url: None,
153 internal_database_url: Some(info.database_url.clone()),
154 tenant_type: TenantType::Cloud,
155 external_db_access: info.external_db_access,
156 sync_token: None,
157 shared_container_db: None,
158 }
159 }
160
161 #[must_use]
162 pub const fn uses_shared_container(&self) -> bool {
163 self.shared_container_db.is_some()
164 }
165
166 #[must_use]
167 pub fn has_database_url(&self) -> bool {
168 match self.tenant_type {
169 TenantType::Cloud => self
170 .internal_database_url
171 .as_ref()
172 .is_some_and(|url| !url.is_empty()),
173 TenantType::Local => self
174 .database_url
175 .as_ref()
176 .is_some_and(|url| !url.is_empty()),
177 }
178 }
179
180 #[must_use]
181 pub fn get_local_database_url(&self) -> Option<&String> {
182 self.database_url
183 .as_ref()
184 .or(self.internal_database_url.as_ref())
185 }
186
187 #[must_use]
188 pub const fn is_cloud(&self) -> bool {
189 matches!(self.tenant_type, TenantType::Cloud)
190 }
191
192 #[must_use]
193 pub const fn is_local(&self) -> bool {
194 matches!(self.tenant_type, TenantType::Local)
195 }
196
197 pub fn update_from_tenant_info(&mut self, info: &TenantInfo) {
198 self.name.clone_from(&info.name);
199 self.app_id.clone_from(&info.app_id);
200 self.hostname.clone_from(&info.hostname);
201 self.region.clone_from(&info.region);
202 self.external_db_access = info.external_db_access;
203
204 if !info.database_url.contains(":***@") {
205 self.internal_database_url = Some(info.database_url.clone());
206 }
207 }
208
209 #[must_use]
210 pub fn is_sync_token_missing(&self) -> bool {
211 self.tenant_type == TenantType::Cloud && self.sync_token.is_none()
212 }
213
214 #[must_use]
215 pub fn is_database_url_masked(&self) -> bool {
216 self.internal_database_url
217 .as_ref()
218 .is_some_and(|url| url.contains(":***@") || url.contains(":********@"))
219 }
220
221 #[must_use]
222 pub fn has_missing_credentials(&self) -> bool {
223 self.tenant_type == TenantType::Cloud
224 && (self.is_sync_token_missing() || self.is_database_url_masked())
225 }
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
229pub struct TenantStore {
230 #[validate(nested)]
231 pub tenants: Vec<StoredTenant>,
232
233 pub synced_at: DateTime<Utc>,
234}
235
236impl TenantStore {
237 #[must_use]
238 pub fn new(tenants: Vec<StoredTenant>) -> Self {
239 Self {
240 tenants,
241 synced_at: Utc::now(),
242 }
243 }
244
245 #[must_use]
246 pub fn from_tenant_infos(infos: &[TenantInfo]) -> Self {
247 let tenants = infos.iter().map(StoredTenant::from_tenant_info).collect();
248 Self::new(tenants)
249 }
250
251 pub fn load_from_path(path: &Path) -> Result<Self> {
252 if !path.exists() {
253 return Err(CloudError::TenantsNotSynced.into());
254 }
255
256 let content = fs::read_to_string(path)
257 .with_context(|| format!("Failed to read {}", path.display()))?;
258
259 let store: Self = serde_json::from_str(&content)
260 .map_err(|e| CloudError::TenantsStoreCorrupted { source: e })?;
261
262 store
263 .validate()
264 .map_err(|e| CloudError::TenantsStoreInvalid {
265 message: e.to_string(),
266 })?;
267
268 Ok(store)
269 }
270
271 pub fn save_to_path(&self, path: &Path) -> Result<()> {
272 self.validate()
273 .map_err(|e| CloudError::TenantsStoreInvalid {
274 message: e.to_string(),
275 })?;
276
277 if let Some(dir) = path.parent() {
278 fs::create_dir_all(dir)?;
279
280 let gitignore_path = dir.join(".gitignore");
281 if !gitignore_path.exists() {
282 fs::write(&gitignore_path, "*\n")?;
283 }
284 }
285
286 let content = serde_json::to_string_pretty(self)?;
287 fs::write(path, content)?;
288
289 #[cfg(unix)]
290 {
291 use std::os::unix::fs::PermissionsExt;
292 let mut perms = fs::metadata(path)?.permissions();
293 perms.set_mode(0o600);
294 fs::set_permissions(path, perms)?;
295 }
296
297 Ok(())
298 }
299
300 #[must_use]
301 pub fn find_tenant(&self, id: &str) -> Option<&StoredTenant> {
302 self.tenants.iter().find(|t| t.id == id)
303 }
304
305 #[must_use]
306 pub fn is_empty(&self) -> bool {
307 self.tenants.is_empty()
308 }
309
310 #[must_use]
311 pub fn len(&self) -> usize {
312 self.tenants.len()
313 }
314
315 #[must_use]
316 pub fn is_stale(&self, max_age: chrono::Duration) -> bool {
317 let age = Utc::now() - self.synced_at;
318 age > max_age
319 }
320}
321
322impl Default for TenantStore {
323 fn default() -> Self {
324 Self {
325 tenants: Vec::new(),
326 synced_at: Utc::now(),
327 }
328 }
329}