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