Skip to main content

systemprompt_cloud/tenants/
mod.rs

1//! On-disk representation of cloud tenants the CLI knows about.
2//!
3//! [`StoredTenant`] is the per-tenant record; [`TenantStore`] (in
4//! `tenant_store.rs`) is the persistent map keyed by tenant id.
5
6mod tenant_store;
7
8use serde::{Deserialize, Serialize};
9use validator::Validate;
10
11use crate::api_client::TenantInfo;
12
13pub use tenant_store::TenantStore;
14
15#[derive(Debug)]
16pub struct NewCloudTenantParams {
17    pub id: String,
18    pub name: String,
19    pub app_id: Option<String>,
20    pub hostname: Option<String>,
21    pub region: Option<String>,
22    pub database_url: Option<String>,
23    pub internal_database_url: String,
24    pub external_db_access: bool,
25    pub sync_token: Option<String>,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
29#[serde(rename_all = "snake_case")]
30pub enum TenantType {
31    #[default]
32    Local,
33    Cloud,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
37pub struct StoredTenant {
38    #[validate(length(min = 1, message = "Tenant ID cannot be empty"))]
39    pub id: String,
40
41    #[validate(length(min = 1, message = "Tenant name cannot be empty"))]
42    pub name: String,
43
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub app_id: Option<String>,
46
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub hostname: Option<String>,
49
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub region: Option<String>,
52
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub database_url: Option<String>,
55
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub internal_database_url: Option<String>,
58
59    #[serde(default)]
60    pub tenant_type: TenantType,
61
62    #[serde(default)]
63    pub external_db_access: bool,
64
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub sync_token: Option<String>,
67
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub shared_container_db: Option<String>,
70}
71
72impl StoredTenant {
73    #[must_use]
74    pub fn new(id: String, name: String) -> Self {
75        Self {
76            id,
77            name,
78            app_id: None,
79            hostname: None,
80            region: None,
81            database_url: None,
82            internal_database_url: None,
83            tenant_type: TenantType::default(),
84            external_db_access: false,
85            sync_token: None,
86            shared_container_db: None,
87        }
88    }
89
90    #[must_use]
91    pub const fn new_local(id: String, name: String, database_url: String) -> Self {
92        Self {
93            id,
94            name,
95            app_id: None,
96            hostname: None,
97            region: None,
98            database_url: Some(database_url),
99            internal_database_url: None,
100            tenant_type: TenantType::Local,
101            external_db_access: false,
102            sync_token: None,
103            shared_container_db: None,
104        }
105    }
106
107    #[must_use]
108    pub const fn new_local_shared(
109        id: String,
110        name: String,
111        database_url: String,
112        shared_container_db: String,
113    ) -> Self {
114        Self {
115            id,
116            name,
117            app_id: None,
118            hostname: None,
119            region: None,
120            database_url: Some(database_url),
121            internal_database_url: None,
122            tenant_type: TenantType::Local,
123            external_db_access: false,
124            sync_token: None,
125            shared_container_db: Some(shared_container_db),
126        }
127    }
128
129    #[must_use]
130    pub fn new_cloud(params: NewCloudTenantParams) -> Self {
131        Self {
132            id: params.id,
133            name: params.name,
134            app_id: params.app_id,
135            hostname: params.hostname,
136            region: params.region,
137            database_url: params.database_url,
138            internal_database_url: Some(params.internal_database_url),
139            tenant_type: TenantType::Cloud,
140            external_db_access: params.external_db_access,
141            sync_token: params.sync_token,
142            shared_container_db: None,
143        }
144    }
145
146    #[must_use]
147    pub fn from_tenant_info(info: &TenantInfo) -> Self {
148        Self {
149            id: info.id.clone(),
150            name: info.name.clone(),
151            app_id: info.app_id.clone(),
152            hostname: info.hostname.clone(),
153            region: info.region.clone(),
154            database_url: None,
155            internal_database_url: Some(info.database_url.clone()),
156            tenant_type: TenantType::Cloud,
157            external_db_access: info.external_db_access,
158            sync_token: None,
159            shared_container_db: None,
160        }
161    }
162
163    #[must_use]
164    pub const fn uses_shared_container(&self) -> bool {
165        self.shared_container_db.is_some()
166    }
167
168    #[must_use]
169    pub fn has_database_url(&self) -> bool {
170        match self.tenant_type {
171            TenantType::Cloud => self
172                .internal_database_url
173                .as_ref()
174                .is_some_and(|url| !url.is_empty()),
175            TenantType::Local => self
176                .database_url
177                .as_ref()
178                .is_some_and(|url| !url.is_empty()),
179        }
180    }
181
182    #[must_use]
183    pub fn get_local_database_url(&self) -> Option<&String> {
184        self.database_url
185            .as_ref()
186            .or(self.internal_database_url.as_ref())
187    }
188
189    #[must_use]
190    pub const fn is_cloud(&self) -> bool {
191        matches!(self.tenant_type, TenantType::Cloud)
192    }
193
194    #[must_use]
195    pub const fn is_local(&self) -> bool {
196        matches!(self.tenant_type, TenantType::Local)
197    }
198
199    pub fn update_from_tenant_info(&mut self, info: &TenantInfo) {
200        self.name.clone_from(&info.name);
201        self.app_id.clone_from(&info.app_id);
202        self.hostname.clone_from(&info.hostname);
203        self.region.clone_from(&info.region);
204        self.external_db_access = info.external_db_access;
205
206        if !info.database_url.contains(":***@") {
207            self.internal_database_url = Some(info.database_url.clone());
208        }
209    }
210
211    #[must_use]
212    pub fn is_sync_token_missing(&self) -> bool {
213        self.tenant_type == TenantType::Cloud && self.sync_token.is_none()
214    }
215
216    #[must_use]
217    pub fn is_database_url_masked(&self) -> bool {
218        self.internal_database_url
219            .as_ref()
220            .is_some_and(|url| url.contains(":***@") || url.contains(":********@"))
221    }
222
223    #[must_use]
224    pub fn has_missing_credentials(&self) -> bool {
225        self.tenant_type == TenantType::Cloud
226            && (self.is_sync_token_missing() || self.is_database_url_masked())
227    }
228}