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 #[error("HTTP_HOST must not be empty")]
33 EmptyHttpHost,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
38pub enum DatabaseBackend {
39 Mysql,
41 Mariadb,
43 Postgres,
45 Sqlite,
47}
48
49impl std::fmt::Display for DatabaseBackend {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 match self {
52 Self::Mysql => write!(f, "mysql"),
53 Self::Mariadb => write!(f, "mariadb"),
54 Self::Postgres => write!(f, "postgres"),
55 Self::Sqlite => write!(f, "sqlite"),
56 }
57 }
58}
59
60impl DatabaseBackend {
61 #[must_use]
63 pub fn default_port(self) -> u16 {
64 match self {
65 Self::Postgres => 5432,
66 Self::Mysql | Self::Mariadb => 3306,
67 Self::Sqlite => 0,
68 }
69 }
70
71 #[must_use]
73 pub fn default_user(self) -> &'static str {
74 match self {
75 Self::Mysql | Self::Mariadb => "root",
76 Self::Postgres => "postgres",
77 Self::Sqlite => "",
78 }
79 }
80}
81
82#[derive(Clone)]
87pub struct DatabaseConfig {
88 pub backend: DatabaseBackend,
90
91 pub host: String,
93
94 pub port: u16,
96
97 pub user: String,
99
100 pub password: Option<String>,
102
103 pub name: Option<String>,
105
106 pub charset: Option<String>,
108
109 pub ssl: bool,
111
112 pub ssl_ca: Option<String>,
114
115 pub ssl_cert: Option<String>,
117
118 pub ssl_key: Option<String>,
120
121 pub ssl_verify_cert: bool,
123
124 pub read_only: bool,
126
127 pub max_pool_size: u32,
129
130 pub connection_timeout: Option<u64>,
132
133 pub query_timeout: Option<u64>,
138}
139
140impl std::fmt::Debug for DatabaseConfig {
141 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142 f.debug_struct("DatabaseConfig")
143 .field("backend", &self.backend)
144 .field("host", &self.host)
145 .field("port", &self.port)
146 .field("user", &self.user)
147 .field("password", &"[REDACTED]")
148 .field("name", &self.name)
149 .field("charset", &self.charset)
150 .field("ssl", &self.ssl)
151 .field("ssl_ca", &self.ssl_ca)
152 .field("ssl_cert", &self.ssl_cert)
153 .field("ssl_key", &self.ssl_key)
154 .field("ssl_verify_cert", &self.ssl_verify_cert)
155 .field("read_only", &self.read_only)
156 .field("max_pool_size", &self.max_pool_size)
157 .field("connection_timeout", &self.connection_timeout)
158 .field("query_timeout", &self.query_timeout)
159 .finish()
160 }
161}
162
163impl DatabaseConfig {
164 pub const DEFAULT_BACKEND: DatabaseBackend = DatabaseBackend::Mysql;
166 pub const DEFAULT_HOST: &'static str = "localhost";
168 pub const DEFAULT_SSL: bool = false;
170 pub const DEFAULT_SSL_VERIFY_CERT: bool = true;
172 pub const DEFAULT_READ_ONLY: bool = true;
174 pub const DEFAULT_MAX_POOL_SIZE: u32 = 5;
176 pub const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 600;
178 pub const DEFAULT_MAX_LIFETIME_SECS: u64 = 1800;
180 pub const DEFAULT_MIN_CONNECTIONS: u32 = 1;
182 pub const DEFAULT_QUERY_TIMEOUT_SECS: u64 = 30;
184
185 pub fn validate(&self) -> Result<(), Vec<ConfigError>> {
191 let mut errors = Vec::new();
192
193 if self.backend == DatabaseBackend::Sqlite && self.name.as_deref().unwrap_or_default().is_empty() {
194 errors.push(ConfigError::MissingSqliteDbName);
195 }
196
197 if self.ssl {
198 for (name, path) in [
199 ("DB_SSL_CA", &self.ssl_ca),
200 ("DB_SSL_CERT", &self.ssl_cert),
201 ("DB_SSL_KEY", &self.ssl_key),
202 ] {
203 if let Some(path) = path
204 && !std::path::Path::new(path).exists()
205 {
206 errors.push(ConfigError::SslCertNotFound(name.into(), path.clone()));
207 }
208 }
209 }
210
211 errors.is_empty().then_some(()).ok_or(errors)
212 }
213}
214
215impl Default for DatabaseConfig {
216 fn default() -> Self {
217 Self {
218 backend: Self::DEFAULT_BACKEND,
219 host: Self::DEFAULT_HOST.into(),
220 port: Self::DEFAULT_BACKEND.default_port(),
221 user: Self::DEFAULT_BACKEND.default_user().into(),
222 password: None,
223 name: None,
224 charset: None,
225 ssl: Self::DEFAULT_SSL,
226 ssl_ca: None,
227 ssl_cert: None,
228 ssl_key: None,
229 ssl_verify_cert: Self::DEFAULT_SSL_VERIFY_CERT,
230 read_only: Self::DEFAULT_READ_ONLY,
231 max_pool_size: Self::DEFAULT_MAX_POOL_SIZE,
232 connection_timeout: None,
233 query_timeout: None,
234 }
235 }
236}
237
238#[derive(Clone, Debug)]
240pub struct HttpConfig {
241 pub host: String,
243
244 pub port: u16,
246
247 pub allowed_origins: Vec<String>,
249
250 pub allowed_hosts: Vec<String>,
252}
253
254impl HttpConfig {
255 pub const DEFAULT_HOST: &'static str = "127.0.0.1";
257 pub const DEFAULT_PORT: u16 = 9001;
259
260 #[must_use]
262 pub fn default_allowed_origins() -> Vec<String> {
263 vec![
264 "http://localhost".into(),
265 "http://127.0.0.1".into(),
266 "https://localhost".into(),
267 "https://127.0.0.1".into(),
268 ]
269 }
270
271 #[must_use]
273 pub fn default_allowed_hosts() -> Vec<String> {
274 vec!["localhost".into(), "127.0.0.1".into()]
275 }
276
277 pub fn validate(&self) -> Result<(), Vec<ConfigError>> {
283 let mut errors = Vec::new();
284
285 if self.host.trim().is_empty() {
286 errors.push(ConfigError::EmptyHttpHost);
287 }
288
289 errors.is_empty().then_some(()).ok_or(errors)
290 }
291}
292
293#[derive(Clone, Debug)]
301pub struct Config {
302 pub database: DatabaseConfig,
304
305 pub http: Option<HttpConfig>,
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 fn db_config(backend: DatabaseBackend) -> DatabaseConfig {
314 DatabaseConfig {
315 backend,
316 port: backend.default_port(),
317 user: backend.default_user().into(),
318 ..DatabaseConfig::default()
319 }
320 }
321
322 fn base_config(backend: DatabaseBackend) -> Config {
323 Config {
324 database: db_config(backend),
325 http: None,
326 }
327 }
328
329 fn mysql_config() -> Config {
330 Config {
331 database: DatabaseConfig {
332 port: 3306,
333 user: "root".into(),
334 password: Some("secret".into()),
335 ..db_config(DatabaseBackend::Mysql)
336 },
337 ..base_config(DatabaseBackend::Mysql)
338 }
339 }
340
341 #[test]
342 fn debug_redacts_password() {
343 let config = Config {
344 database: DatabaseConfig {
345 password: Some("super_secret_password".into()),
346 ..mysql_config().database
347 },
348 ..mysql_config()
349 };
350 let debug_output = format!("{config:?}");
351 assert!(
352 !debug_output.contains("super_secret_password"),
353 "password leaked in debug output: {debug_output}"
354 );
355 assert!(
356 debug_output.contains("[REDACTED]"),
357 "expected [REDACTED] in debug output: {debug_output}"
358 );
359 }
360
361 #[test]
362 fn valid_mysql_config_passes() {
363 assert!(mysql_config().database.validate().is_ok());
364 }
365
366 #[test]
367 fn valid_postgres_config_passes() {
368 let config = Config {
369 database: DatabaseConfig {
370 user: "pguser".into(),
371 port: 5432,
372 ..db_config(DatabaseBackend::Postgres)
373 },
374 ..base_config(DatabaseBackend::Postgres)
375 };
376 assert!(config.database.validate().is_ok());
377 }
378
379 #[test]
380 fn valid_sqlite_config_passes() {
381 let config = Config {
382 database: DatabaseConfig {
383 name: Some("./test.db".into()),
384 ..db_config(DatabaseBackend::Sqlite)
385 },
386 ..base_config(DatabaseBackend::Sqlite)
387 };
388 assert!(config.database.validate().is_ok());
389 }
390
391 #[test]
392 fn defaults_resolved_at_construction() {
393 let mysql = base_config(DatabaseBackend::Mysql);
394 assert_eq!(mysql.database.host, "localhost");
395 assert_eq!(mysql.database.port, 3306);
396 assert_eq!(mysql.database.user, "root");
397
398 let pg = base_config(DatabaseBackend::Postgres);
399 assert_eq!(pg.database.port, 5432);
400 assert_eq!(pg.database.user, "postgres");
401
402 let sqlite = base_config(DatabaseBackend::Sqlite);
403 assert_eq!(sqlite.database.port, 0);
404 assert_eq!(sqlite.database.user, "");
405 }
406
407 #[test]
408 fn explicit_values_override_defaults() {
409 let config = Config {
410 database: DatabaseConfig {
411 host: "dbserver.example.com".into(),
412 port: 13306,
413 user: "myuser".into(),
414 ..db_config(DatabaseBackend::Mysql)
415 },
416 ..base_config(DatabaseBackend::Mysql)
417 };
418 assert_eq!(config.database.host, "dbserver.example.com");
419 assert_eq!(config.database.port, 13306);
420 assert_eq!(config.database.user, "myuser");
421 }
422
423 #[test]
424 fn mysql_without_user_gets_default() {
425 let config = base_config(DatabaseBackend::Mysql);
426 assert_eq!(config.database.user, "root");
427 assert!(config.database.validate().is_ok());
428 }
429
430 #[test]
431 fn sqlite_requires_db_name() {
432 let config = base_config(DatabaseBackend::Sqlite);
433 let errors = config
434 .database
435 .validate()
436 .expect_err("sqlite without db name must fail");
437 assert!(errors.iter().any(|e| matches!(e, ConfigError::MissingSqliteDbName)));
438 }
439
440 #[test]
441 fn multiple_errors_accumulated() {
442 let config = Config {
443 database: DatabaseConfig {
444 ssl: true,
445 ssl_ca: Some("/nonexistent/ca.pem".into()),
446 ssl_cert: Some("/nonexistent/cert.pem".into()),
447 ssl_key: Some("/nonexistent/key.pem".into()),
448 ..db_config(DatabaseBackend::Mysql)
449 },
450 ..base_config(DatabaseBackend::Mysql)
451 };
452 let errors = config
453 .database
454 .validate()
455 .expect_err("missing ssl cert files must fail");
456 assert!(
457 errors.len() >= 3,
458 "expected at least 3 errors, got {}: {errors:?}",
459 errors.len()
460 );
461 }
462
463 #[test]
464 fn mariadb_backend_is_valid() {
465 let config = base_config(DatabaseBackend::Mariadb);
466 assert!(config.database.validate().is_ok());
467 }
468
469 #[test]
470 fn query_timeout_default_is_none() {
471 let config = DatabaseConfig::default();
472 assert!(config.query_timeout.is_none());
473 }
474
475 fn http_config() -> HttpConfig {
476 HttpConfig {
477 host: HttpConfig::DEFAULT_HOST.into(),
478 port: HttpConfig::DEFAULT_PORT,
479 allowed_origins: HttpConfig::default_allowed_origins(),
480 allowed_hosts: HttpConfig::default_allowed_hosts(),
481 }
482 }
483
484 #[test]
485 fn valid_http_config_passes() {
486 assert!(http_config().validate().is_ok());
487 }
488
489 #[test]
490 fn empty_http_host_rejected() {
491 let config = HttpConfig {
492 host: String::new(),
493 ..http_config()
494 };
495 let errors = config.validate().expect_err("empty host must fail");
496 assert!(errors.iter().any(|e| matches!(e, ConfigError::EmptyHttpHost)));
497 }
498
499 #[test]
500 fn whitespace_http_host_rejected() {
501 let config = HttpConfig {
502 host: " ".into(),
503 ..http_config()
504 };
505 let errors = config.validate().expect_err("whitespace host must fail");
506 assert!(errors.iter().any(|e| matches!(e, ConfigError::EmptyHttpHost)));
507 }
508
509 #[test]
510 fn debug_includes_query_timeout() {
511 let config = Config {
512 database: DatabaseConfig {
513 query_timeout: Some(30),
514 ..db_config(DatabaseBackend::Mysql)
515 },
516 ..base_config(DatabaseBackend::Mysql)
517 };
518 let debug = format!("{config:?}");
519 assert!(
520 debug.contains("query_timeout: Some(30)"),
521 "expected query_timeout in debug output: {debug}"
522 );
523 }
524}