1#[derive(Debug, thiserror::Error)]
22pub enum ConfigError {
23 #[error("DB_NAME (file path) is required for SQLite")]
25 MissingSqliteDbName,
26
27 #[error("{0} file not found: {1}")]
29 SslCertNotFound(String, String),
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
34pub enum DatabaseBackend {
35 Mysql,
37 Mariadb,
39 Postgres,
41 Sqlite,
43}
44
45impl std::fmt::Display for DatabaseBackend {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 match self {
48 Self::Mysql => write!(f, "mysql"),
49 Self::Mariadb => write!(f, "mariadb"),
50 Self::Postgres => write!(f, "postgres"),
51 Self::Sqlite => write!(f, "sqlite"),
52 }
53 }
54}
55
56impl DatabaseBackend {
57 #[must_use]
59 pub fn default_port(self) -> u16 {
60 match self {
61 Self::Postgres => 5432,
62 Self::Mysql | Self::Mariadb => 3306,
63 Self::Sqlite => 0,
64 }
65 }
66
67 #[must_use]
69 pub fn default_user(self) -> &'static str {
70 match self {
71 Self::Mysql | Self::Mariadb => "root",
72 Self::Postgres => "postgres",
73 Self::Sqlite => "",
74 }
75 }
76}
77
78#[derive(Clone)]
83pub struct DatabaseConfig {
84 pub backend: DatabaseBackend,
86
87 pub host: String,
89
90 pub port: u16,
92
93 pub user: String,
95
96 pub password: Option<String>,
98
99 pub name: Option<String>,
101
102 pub charset: Option<String>,
104
105 pub ssl: bool,
107
108 pub ssl_ca: Option<String>,
110
111 pub ssl_cert: Option<String>,
113
114 pub ssl_key: Option<String>,
116
117 pub ssl_verify_cert: bool,
119
120 pub read_only: bool,
122
123 pub max_pool_size: u32,
125}
126
127impl std::fmt::Debug for DatabaseConfig {
128 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129 f.debug_struct("DatabaseConfig")
130 .field("backend", &self.backend)
131 .field("host", &self.host)
132 .field("port", &self.port)
133 .field("user", &self.user)
134 .field("password", &"[REDACTED]")
135 .field("name", &self.name)
136 .field("charset", &self.charset)
137 .field("ssl", &self.ssl)
138 .field("ssl_ca", &self.ssl_ca)
139 .field("ssl_cert", &self.ssl_cert)
140 .field("ssl_key", &self.ssl_key)
141 .field("ssl_verify_cert", &self.ssl_verify_cert)
142 .field("read_only", &self.read_only)
143 .field("max_pool_size", &self.max_pool_size)
144 .finish()
145 }
146}
147
148impl DatabaseConfig {
149 pub const DEFAULT_BACKEND: DatabaseBackend = DatabaseBackend::Mysql;
151 pub const DEFAULT_HOST: &'static str = "localhost";
153 pub const DEFAULT_SSL: bool = false;
155 pub const DEFAULT_SSL_VERIFY_CERT: bool = true;
157 pub const DEFAULT_READ_ONLY: bool = true;
159 pub const DEFAULT_MAX_POOL_SIZE: u32 = 10;
161}
162
163impl Default for DatabaseConfig {
164 fn default() -> Self {
165 Self {
166 backend: Self::DEFAULT_BACKEND,
167 host: Self::DEFAULT_HOST.into(),
168 port: Self::DEFAULT_BACKEND.default_port(),
169 user: Self::DEFAULT_BACKEND.default_user().into(),
170 password: None,
171 name: None,
172 charset: None,
173 ssl: Self::DEFAULT_SSL,
174 ssl_ca: None,
175 ssl_cert: None,
176 ssl_key: None,
177 ssl_verify_cert: Self::DEFAULT_SSL_VERIFY_CERT,
178 read_only: Self::DEFAULT_READ_ONLY,
179 max_pool_size: Self::DEFAULT_MAX_POOL_SIZE,
180 }
181 }
182}
183
184#[derive(Clone, Debug)]
186pub struct HttpConfig {
187 pub host: String,
189
190 pub port: u16,
192
193 pub allowed_origins: Vec<String>,
195
196 pub allowed_hosts: Vec<String>,
198}
199
200impl HttpConfig {
201 pub const DEFAULT_HOST: &'static str = "127.0.0.1";
203 pub const DEFAULT_PORT: u16 = 9001;
205
206 #[must_use]
208 pub fn default_allowed_origins() -> Vec<String> {
209 vec![
210 "http://localhost".into(),
211 "http://127.0.0.1".into(),
212 "https://localhost".into(),
213 "https://127.0.0.1".into(),
214 ]
215 }
216
217 #[must_use]
219 pub fn default_allowed_hosts() -> Vec<String> {
220 vec!["localhost".into(), "127.0.0.1".into()]
221 }
222}
223
224#[derive(Clone, Debug)]
231pub struct Config {
232 pub database: DatabaseConfig,
234
235 pub server: Option<HttpConfig>,
237}
238
239impl Config {
240 pub fn validate(&self) -> Result<(), Vec<ConfigError>> {
246 let mut errors = Vec::new();
247
248 if self.database.backend == DatabaseBackend::Sqlite
249 && self.database.name.as_deref().unwrap_or_default().is_empty()
250 {
251 errors.push(ConfigError::MissingSqliteDbName);
252 }
253
254 if self.database.ssl {
255 for (name, path) in [
256 ("DB_SSL_CA", &self.database.ssl_ca),
257 ("DB_SSL_CERT", &self.database.ssl_cert),
258 ("DB_SSL_KEY", &self.database.ssl_key),
259 ] {
260 if let Some(path) = path
261 && !std::path::Path::new(path).exists()
262 {
263 errors.push(ConfigError::SslCertNotFound(name.into(), path.clone()));
264 }
265 }
266 }
267
268 if errors.is_empty() { Ok(()) } else { Err(errors) }
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 fn db_config(backend: DatabaseBackend) -> DatabaseConfig {
277 DatabaseConfig {
278 backend,
279 port: backend.default_port(),
280 user: backend.default_user().into(),
281 ..DatabaseConfig::default()
282 }
283 }
284
285 fn base_config(backend: DatabaseBackend) -> Config {
286 Config {
287 database: db_config(backend),
288 server: None,
289 }
290 }
291
292 fn mysql_config() -> Config {
293 Config {
294 database: DatabaseConfig {
295 port: 3306,
296 user: "root".into(),
297 password: Some("secret".into()),
298 ..db_config(DatabaseBackend::Mysql)
299 },
300 ..base_config(DatabaseBackend::Mysql)
301 }
302 }
303
304 #[test]
305 fn debug_redacts_password() {
306 let config = Config {
307 database: DatabaseConfig {
308 password: Some("super_secret_password".into()),
309 ..mysql_config().database
310 },
311 ..mysql_config()
312 };
313 let debug_output = format!("{config:?}");
314 assert!(
315 !debug_output.contains("super_secret_password"),
316 "password leaked in debug output: {debug_output}"
317 );
318 assert!(
319 debug_output.contains("[REDACTED]"),
320 "expected [REDACTED] in debug output: {debug_output}"
321 );
322 }
323
324 #[test]
325 fn valid_mysql_config_passes() {
326 assert!(mysql_config().validate().is_ok());
327 }
328
329 #[test]
330 fn valid_postgres_config_passes() {
331 let config = Config {
332 database: DatabaseConfig {
333 user: "pguser".into(),
334 port: 5432,
335 ..db_config(DatabaseBackend::Postgres)
336 },
337 ..base_config(DatabaseBackend::Postgres)
338 };
339 assert!(config.validate().is_ok());
340 }
341
342 #[test]
343 fn valid_sqlite_config_passes() {
344 let config = Config {
345 database: DatabaseConfig {
346 name: Some("./test.db".into()),
347 ..db_config(DatabaseBackend::Sqlite)
348 },
349 ..base_config(DatabaseBackend::Sqlite)
350 };
351 assert!(config.validate().is_ok());
352 }
353
354 #[test]
355 fn defaults_resolved_at_construction() {
356 let mysql = base_config(DatabaseBackend::Mysql);
357 assert_eq!(mysql.database.host, "localhost");
358 assert_eq!(mysql.database.port, 3306);
359 assert_eq!(mysql.database.user, "root");
360
361 let pg = base_config(DatabaseBackend::Postgres);
362 assert_eq!(pg.database.port, 5432);
363 assert_eq!(pg.database.user, "postgres");
364
365 let sqlite = base_config(DatabaseBackend::Sqlite);
366 assert_eq!(sqlite.database.port, 0);
367 assert_eq!(sqlite.database.user, "");
368 }
369
370 #[test]
371 fn explicit_values_override_defaults() {
372 let config = Config {
373 database: DatabaseConfig {
374 host: "dbserver.example.com".into(),
375 port: 13306,
376 user: "myuser".into(),
377 ..db_config(DatabaseBackend::Mysql)
378 },
379 ..base_config(DatabaseBackend::Mysql)
380 };
381 assert_eq!(config.database.host, "dbserver.example.com");
382 assert_eq!(config.database.port, 13306);
383 assert_eq!(config.database.user, "myuser");
384 }
385
386 #[test]
387 fn mysql_without_user_gets_default() {
388 let config = base_config(DatabaseBackend::Mysql);
389 assert_eq!(config.database.user, "root");
390 assert!(config.validate().is_ok());
391 }
392
393 #[test]
394 fn sqlite_requires_db_name() {
395 let config = base_config(DatabaseBackend::Sqlite);
396 let errors = config.validate().unwrap_err();
397 assert!(errors.iter().any(|e| matches!(e, ConfigError::MissingSqliteDbName)));
398 }
399
400 #[test]
401 fn multiple_errors_accumulated() {
402 let config = Config {
403 database: DatabaseConfig {
404 ssl: true,
405 ssl_ca: Some("/nonexistent/ca.pem".into()),
406 ssl_cert: Some("/nonexistent/cert.pem".into()),
407 ssl_key: Some("/nonexistent/key.pem".into()),
408 ..db_config(DatabaseBackend::Mysql)
409 },
410 ..base_config(DatabaseBackend::Mysql)
411 };
412 let errors = config.validate().unwrap_err();
413 assert!(
414 errors.len() >= 3,
415 "expected at least 3 errors, got {}: {errors:?}",
416 errors.len()
417 );
418 }
419
420 #[test]
421 fn mariadb_backend_is_valid() {
422 let config = base_config(DatabaseBackend::Mariadb);
423 assert!(config.validate().is_ok());
424 }
425}