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
65impl StoredTenant {
66 #[must_use]
67 pub fn new(id: String, name: String) -> Self {
68 Self {
69 id,
70 name,
71 app_id: None,
72 hostname: None,
73 region: None,
74 database_url: None,
75 internal_database_url: None,
76 tenant_type: TenantType::default(),
77 external_db_access: false,
78 sync_token: None,
79 }
80 }
81
82 #[must_use]
83 pub const fn new_local(id: String, name: String, database_url: String) -> Self {
84 Self {
85 id,
86 name,
87 app_id: None,
88 hostname: None,
89 region: None,
90 database_url: Some(database_url),
91 internal_database_url: None,
92 tenant_type: TenantType::Local,
93 external_db_access: false,
94 sync_token: None,
95 }
96 }
97
98 #[must_use]
99 pub fn new_cloud(params: NewCloudTenantParams) -> Self {
100 Self {
101 id: params.id,
102 name: params.name,
103 app_id: params.app_id,
104 hostname: params.hostname,
105 region: params.region,
106 database_url: params.database_url,
107 internal_database_url: Some(params.internal_database_url),
108 tenant_type: TenantType::Cloud,
109 external_db_access: params.external_db_access,
110 sync_token: params.sync_token,
111 }
112 }
113
114 #[must_use]
115 pub fn from_tenant_info(info: &TenantInfo) -> Self {
116 Self {
117 id: info.id.clone(),
118 name: info.name.clone(),
119 app_id: info.app_id.clone(),
120 hostname: info.hostname.clone(),
121 region: info.region.clone(),
122 database_url: None,
123 internal_database_url: Some(info.database_url.clone()),
124 tenant_type: TenantType::Cloud,
125 external_db_access: info.external_db_access,
126 sync_token: None,
127 }
128 }
129
130 #[must_use]
131 pub fn has_database_url(&self) -> bool {
132 match self.tenant_type {
133 TenantType::Cloud => self
134 .internal_database_url
135 .as_ref()
136 .is_some_and(|url| !url.is_empty()),
137 TenantType::Local => self
138 .database_url
139 .as_ref()
140 .is_some_and(|url| !url.is_empty()),
141 }
142 }
143
144 #[must_use]
145 pub fn get_local_database_url(&self) -> Option<&String> {
146 self.database_url
147 .as_ref()
148 .or(self.internal_database_url.as_ref())
149 }
150
151 #[must_use]
152 pub const fn is_cloud(&self) -> bool {
153 matches!(self.tenant_type, TenantType::Cloud)
154 }
155
156 #[must_use]
157 pub const fn is_local(&self) -> bool {
158 matches!(self.tenant_type, TenantType::Local)
159 }
160
161 pub fn update_from_tenant_info(&mut self, info: &TenantInfo) {
162 self.name.clone_from(&info.name);
163 self.app_id.clone_from(&info.app_id);
164 self.hostname.clone_from(&info.hostname);
165 self.region.clone_from(&info.region);
166 self.external_db_access = info.external_db_access;
167 }
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
171pub struct TenantStore {
172 #[validate(nested)]
173 pub tenants: Vec<StoredTenant>,
174
175 pub synced_at: DateTime<Utc>,
176}
177
178impl TenantStore {
179 #[must_use]
180 pub fn new(tenants: Vec<StoredTenant>) -> Self {
181 Self {
182 tenants,
183 synced_at: Utc::now(),
184 }
185 }
186
187 #[must_use]
188 pub fn from_tenant_infos(infos: &[TenantInfo]) -> Self {
189 let tenants = infos.iter().map(StoredTenant::from_tenant_info).collect();
190 Self::new(tenants)
191 }
192
193 pub fn load_from_path(path: &Path) -> Result<Self> {
194 if !path.exists() {
195 return Err(CloudError::TenantsNotSynced.into());
196 }
197
198 let content = fs::read_to_string(path)
199 .with_context(|| format!("Failed to read {}", path.display()))?;
200
201 let store: Self = serde_json::from_str(&content)
202 .map_err(|e| CloudError::TenantsStoreCorrupted { source: e })?;
203
204 store
205 .validate()
206 .map_err(|e| CloudError::TenantsStoreInvalid {
207 message: e.to_string(),
208 })?;
209
210 Ok(store)
211 }
212
213 pub fn save_to_path(&self, path: &Path) -> Result<()> {
214 self.validate()
215 .map_err(|e| CloudError::TenantsStoreInvalid {
216 message: e.to_string(),
217 })?;
218
219 if let Some(dir) = path.parent() {
220 fs::create_dir_all(dir)?;
221
222 let gitignore_path = dir.join(".gitignore");
223 if !gitignore_path.exists() {
224 fs::write(&gitignore_path, "*\n")?;
225 }
226 }
227
228 let content = serde_json::to_string_pretty(self)?;
229 fs::write(path, content)?;
230
231 #[cfg(unix)]
232 {
233 use std::os::unix::fs::PermissionsExt;
234 let mut perms = fs::metadata(path)?.permissions();
235 perms.set_mode(0o600);
236 fs::set_permissions(path, perms)?;
237 }
238
239 Ok(())
240 }
241
242 #[must_use]
243 pub fn find_tenant(&self, id: &str) -> Option<&StoredTenant> {
244 self.tenants.iter().find(|t| t.id == id)
245 }
246
247 #[must_use]
248 pub fn is_empty(&self) -> bool {
249 self.tenants.is_empty()
250 }
251
252 #[must_use]
253 pub fn len(&self) -> usize {
254 self.tenants.len()
255 }
256
257 #[must_use]
258 pub fn is_stale(&self, max_age: chrono::Duration) -> bool {
259 let age = Utc::now() - self.synced_at;
260 age > max_age
261 }
262}
263
264impl Default for TenantStore {
265 fn default() -> Self {
266 Self {
267 tenants: Vec::new(),
268 synced_at: Utc::now(),
269 }
270 }
271}