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 #[error("DB_PAGE_SIZE must be between 1 and {max}, got {value}")]
37 PageSizeOutOfRange {
38 value: u16,
40 max: u16,
42 },
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
47pub enum DatabaseBackend {
48 Mysql,
50 Mariadb,
52 Postgres,
54 Sqlite,
56}
57
58impl std::fmt::Display for DatabaseBackend {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 match self {
61 Self::Mysql => write!(f, "mysql"),
62 Self::Mariadb => write!(f, "mariadb"),
63 Self::Postgres => write!(f, "postgres"),
64 Self::Sqlite => write!(f, "sqlite"),
65 }
66 }
67}
68
69impl DatabaseBackend {
70 #[must_use]
72 pub fn default_port(self) -> u16 {
73 match self {
74 Self::Postgres => 5432,
75 Self::Mysql | Self::Mariadb => 3306,
76 Self::Sqlite => 0,
77 }
78 }
79
80 #[must_use]
82 pub fn default_user(self) -> &'static str {
83 match self {
84 Self::Mysql | Self::Mariadb => "root",
85 Self::Postgres => "postgres",
86 Self::Sqlite => "",
87 }
88 }
89}
90
91#[derive(Clone)]
96pub struct DatabaseConfig {
97 pub backend: DatabaseBackend,
99
100 pub host: String,
102
103 pub port: u16,
105
106 pub user: String,
108
109 pub password: Option<String>,
111
112 pub name: Option<String>,
114
115 pub charset: Option<String>,
117
118 pub ssl: bool,
120
121 pub ssl_ca: Option<String>,
123
124 pub ssl_cert: Option<String>,
126
127 pub ssl_key: Option<String>,
129
130 pub ssl_verify_cert: bool,
132
133 pub read_only: bool,
135
136 pub max_pool_size: u32,
138
139 pub connection_timeout: Option<u64>,
141
142 pub query_timeout: Option<u64>,
147
148 pub page_size: u16,
153}
154
155impl std::fmt::Debug for DatabaseConfig {
156 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157 f.debug_struct("DatabaseConfig")
158 .field("backend", &self.backend)
159 .field("host", &self.host)
160 .field("port", &self.port)
161 .field("user", &self.user)
162 .field("password", &"[REDACTED]")
163 .field("name", &self.name)
164 .field("charset", &self.charset)
165 .field("ssl", &self.ssl)
166 .field("ssl_ca", &self.ssl_ca)
167 .field("ssl_cert", &self.ssl_cert)
168 .field("ssl_key", &self.ssl_key)
169 .field("ssl_verify_cert", &self.ssl_verify_cert)
170 .field("read_only", &self.read_only)
171 .field("max_pool_size", &self.max_pool_size)
172 .field("connection_timeout", &self.connection_timeout)
173 .field("query_timeout", &self.query_timeout)
174 .field("page_size", &self.page_size)
175 .finish()
176 }
177}
178
179impl DatabaseConfig {
180 pub const DEFAULT_BACKEND: DatabaseBackend = DatabaseBackend::Mysql;
182 pub const DEFAULT_HOST: &'static str = "localhost";
184 pub const DEFAULT_SSL: bool = false;
186 pub const DEFAULT_SSL_VERIFY_CERT: bool = true;
188 pub const DEFAULT_READ_ONLY: bool = true;
190 pub const DEFAULT_MAX_POOL_SIZE: u32 = 5;
192 pub const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 600;
194 pub const DEFAULT_MAX_LIFETIME_SECS: u64 = 1800;
196 pub const DEFAULT_MIN_CONNECTIONS: u32 = 1;
198 pub const DEFAULT_QUERY_TIMEOUT_SECS: u64 = 30;
200 pub const DEFAULT_PAGE_SIZE: u16 = 100;
202 pub const MAX_PAGE_SIZE: u16 = 500;
204
205 pub fn validate(&self) -> Result<(), Vec<ConfigError>> {
211 let mut errors = Vec::new();
212
213 if self.backend == DatabaseBackend::Sqlite && self.name.as_deref().unwrap_or_default().is_empty() {
214 errors.push(ConfigError::MissingSqliteDbName);
215 }
216
217 if self.ssl {
218 for (name, path) in [
219 ("DB_SSL_CA", &self.ssl_ca),
220 ("DB_SSL_CERT", &self.ssl_cert),
221 ("DB_SSL_KEY", &self.ssl_key),
222 ] {
223 if let Some(path) = path
224 && !std::path::Path::new(path).exists()
225 {
226 errors.push(ConfigError::SslCertNotFound(name.into(), path.clone()));
227 }
228 }
229 }
230
231 if !(1..=Self::MAX_PAGE_SIZE).contains(&self.page_size) {
232 errors.push(ConfigError::PageSizeOutOfRange {
233 value: self.page_size,
234 max: Self::MAX_PAGE_SIZE,
235 });
236 }
237
238 errors.is_empty().then_some(()).ok_or(errors)
239 }
240}
241
242impl Default for DatabaseConfig {
243 fn default() -> Self {
244 Self {
245 backend: Self::DEFAULT_BACKEND,
246 host: Self::DEFAULT_HOST.into(),
247 port: Self::DEFAULT_BACKEND.default_port(),
248 user: Self::DEFAULT_BACKEND.default_user().into(),
249 password: None,
250 name: None,
251 charset: None,
252 ssl: Self::DEFAULT_SSL,
253 ssl_ca: None,
254 ssl_cert: None,
255 ssl_key: None,
256 ssl_verify_cert: Self::DEFAULT_SSL_VERIFY_CERT,
257 read_only: Self::DEFAULT_READ_ONLY,
258 max_pool_size: Self::DEFAULT_MAX_POOL_SIZE,
259 connection_timeout: None,
260 query_timeout: None,
261 page_size: Self::DEFAULT_PAGE_SIZE,
262 }
263 }
264}
265
266#[derive(Clone, Debug)]
268pub struct HttpConfig {
269 pub host: String,
271
272 pub port: u16,
274
275 pub allowed_origins: Vec<String>,
277
278 pub allowed_hosts: Vec<String>,
280}
281
282impl HttpConfig {
283 pub const DEFAULT_HOST: &'static str = "127.0.0.1";
285 pub const DEFAULT_PORT: u16 = 9001;
287
288 #[must_use]
290 pub fn default_allowed_origins() -> Vec<String> {
291 vec![
292 "http://localhost".into(),
293 "http://127.0.0.1".into(),
294 "https://localhost".into(),
295 "https://127.0.0.1".into(),
296 ]
297 }
298
299 #[must_use]
301 pub fn default_allowed_hosts() -> Vec<String> {
302 vec!["localhost".into(), "127.0.0.1".into()]
303 }
304
305 pub fn validate(&self) -> Result<(), Vec<ConfigError>> {
311 let mut errors = Vec::new();
312
313 if self.host.trim().is_empty() {
314 errors.push(ConfigError::EmptyHttpHost);
315 }
316
317 errors.is_empty().then_some(()).ok_or(errors)
318 }
319}
320
321#[derive(Clone, Debug)]
329pub struct Config {
330 pub database: DatabaseConfig,
332
333 pub http: Option<HttpConfig>,
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 fn db_config(backend: DatabaseBackend) -> DatabaseConfig {
342 DatabaseConfig {
343 backend,
344 port: backend.default_port(),
345 user: backend.default_user().into(),
346 ..DatabaseConfig::default()
347 }
348 }
349
350 fn base_config(backend: DatabaseBackend) -> Config {
351 Config {
352 database: db_config(backend),
353 http: None,
354 }
355 }
356
357 fn mysql_config() -> Config {
358 Config {
359 database: DatabaseConfig {
360 port: 3306,
361 user: "root".into(),
362 password: Some("secret".into()),
363 ..db_config(DatabaseBackend::Mysql)
364 },
365 ..base_config(DatabaseBackend::Mysql)
366 }
367 }
368
369 #[test]
370 fn debug_redacts_password() {
371 let config = Config {
372 database: DatabaseConfig {
373 password: Some("super_secret_password".into()),
374 ..mysql_config().database
375 },
376 ..mysql_config()
377 };
378 let debug_output = format!("{config:?}");
379 assert!(
380 !debug_output.contains("super_secret_password"),
381 "password leaked in debug output: {debug_output}"
382 );
383 assert!(
384 debug_output.contains("[REDACTED]"),
385 "expected [REDACTED] in debug output: {debug_output}"
386 );
387 }
388
389 #[test]
390 fn valid_mysql_config_passes() {
391 assert!(mysql_config().database.validate().is_ok());
392 }
393
394 #[test]
395 fn valid_postgres_config_passes() {
396 let config = Config {
397 database: DatabaseConfig {
398 user: "pguser".into(),
399 port: 5432,
400 ..db_config(DatabaseBackend::Postgres)
401 },
402 ..base_config(DatabaseBackend::Postgres)
403 };
404 assert!(config.database.validate().is_ok());
405 }
406
407 #[test]
408 fn valid_sqlite_config_passes() {
409 let config = Config {
410 database: DatabaseConfig {
411 name: Some("./test.db".into()),
412 ..db_config(DatabaseBackend::Sqlite)
413 },
414 ..base_config(DatabaseBackend::Sqlite)
415 };
416 assert!(config.database.validate().is_ok());
417 }
418
419 #[test]
420 fn defaults_resolved_at_construction() {
421 let mysql = base_config(DatabaseBackend::Mysql);
422 assert_eq!(mysql.database.host, "localhost");
423 assert_eq!(mysql.database.port, 3306);
424 assert_eq!(mysql.database.user, "root");
425
426 let pg = base_config(DatabaseBackend::Postgres);
427 assert_eq!(pg.database.port, 5432);
428 assert_eq!(pg.database.user, "postgres");
429
430 let sqlite = base_config(DatabaseBackend::Sqlite);
431 assert_eq!(sqlite.database.port, 0);
432 assert_eq!(sqlite.database.user, "");
433 }
434
435 #[test]
436 fn explicit_values_override_defaults() {
437 let config = Config {
438 database: DatabaseConfig {
439 host: "dbserver.example.com".into(),
440 port: 13306,
441 user: "myuser".into(),
442 ..db_config(DatabaseBackend::Mysql)
443 },
444 ..base_config(DatabaseBackend::Mysql)
445 };
446 assert_eq!(config.database.host, "dbserver.example.com");
447 assert_eq!(config.database.port, 13306);
448 assert_eq!(config.database.user, "myuser");
449 }
450
451 #[test]
452 fn mysql_without_user_gets_default() {
453 let config = base_config(DatabaseBackend::Mysql);
454 assert_eq!(config.database.user, "root");
455 assert!(config.database.validate().is_ok());
456 }
457
458 #[test]
459 fn sqlite_requires_db_name() {
460 let config = base_config(DatabaseBackend::Sqlite);
461 let errors = config
462 .database
463 .validate()
464 .expect_err("sqlite without db name must fail");
465 assert!(errors.iter().any(|e| matches!(e, ConfigError::MissingSqliteDbName)));
466 }
467
468 #[test]
469 fn multiple_errors_accumulated() {
470 let config = Config {
471 database: DatabaseConfig {
472 ssl: true,
473 ssl_ca: Some("/nonexistent/ca.pem".into()),
474 ssl_cert: Some("/nonexistent/cert.pem".into()),
475 ssl_key: Some("/nonexistent/key.pem".into()),
476 ..db_config(DatabaseBackend::Mysql)
477 },
478 ..base_config(DatabaseBackend::Mysql)
479 };
480 let errors = config
481 .database
482 .validate()
483 .expect_err("missing ssl cert files must fail");
484 assert!(
485 errors.len() >= 3,
486 "expected at least 3 errors, got {}: {errors:?}",
487 errors.len()
488 );
489 }
490
491 #[test]
492 fn mariadb_backend_is_valid() {
493 let config = base_config(DatabaseBackend::Mariadb);
494 assert!(config.database.validate().is_ok());
495 }
496
497 #[test]
498 fn query_timeout_default_is_none() {
499 let config = DatabaseConfig::default();
500 assert!(config.query_timeout.is_none());
501 }
502
503 #[test]
504 fn page_size_default_is_100() {
505 let config = DatabaseConfig::default();
506 assert_eq!(config.page_size, 100);
507 }
508
509 #[test]
510 fn page_size_zero_rejected() {
511 let config = DatabaseConfig {
512 page_size: 0,
513 ..mysql_config().database
514 };
515 let errors = config.validate().expect_err("page_size=0 must be rejected");
516 assert!(
517 errors
518 .iter()
519 .any(|e| matches!(e, ConfigError::PageSizeOutOfRange { value: 0, max: 500 })),
520 "expected PageSizeOutOfRange {{ value: 0, max: 500 }}, got {errors:?}"
521 );
522 }
523
524 #[test]
525 fn page_size_above_max_rejected() {
526 let config = DatabaseConfig {
527 page_size: 501,
528 ..mysql_config().database
529 };
530 let errors = config.validate().expect_err("page_size above max must be rejected");
531 assert!(
532 errors
533 .iter()
534 .any(|e| matches!(e, ConfigError::PageSizeOutOfRange { value: 501, max: 500 })),
535 "expected PageSizeOutOfRange {{ value: 501, max: 500 }}, got {errors:?}"
536 );
537 }
538
539 #[test]
540 fn page_size_at_min_accepted() {
541 let config = DatabaseConfig {
542 page_size: 1,
543 ..mysql_config().database
544 };
545 assert!(config.validate().is_ok(), "page_size=1 must be accepted");
546 }
547
548 #[test]
549 fn page_size_at_max_accepted() {
550 let config = DatabaseConfig {
551 page_size: DatabaseConfig::MAX_PAGE_SIZE,
552 ..mysql_config().database
553 };
554 assert!(config.validate().is_ok(), "page_size=MAX_PAGE_SIZE must be accepted");
555 }
556
557 #[test]
558 fn page_size_errors_accumulate_with_others() {
559 let config = Config {
560 database: DatabaseConfig {
561 page_size: 0,
562 ..db_config(DatabaseBackend::Sqlite)
563 },
564 ..base_config(DatabaseBackend::Sqlite)
565 };
566 let errors = config
567 .database
568 .validate()
569 .expect_err("multiple errors should be accumulated");
570 assert!(
571 errors.iter().any(|e| matches!(e, ConfigError::MissingSqliteDbName)),
572 "expected MissingSqliteDbName in {errors:?}"
573 );
574 assert!(
575 errors
576 .iter()
577 .any(|e| matches!(e, ConfigError::PageSizeOutOfRange { value: 0, .. })),
578 "expected PageSizeOutOfRange in {errors:?}"
579 );
580 }
581
582 #[test]
583 fn debug_includes_page_size() {
584 let config = DatabaseConfig {
585 page_size: 250,
586 ..mysql_config().database
587 };
588 let debug = format!("{config:?}");
589 assert!(
590 debug.contains("page_size: 250"),
591 "expected page_size in debug output: {debug}"
592 );
593 }
594
595 fn http_config() -> HttpConfig {
596 HttpConfig {
597 host: HttpConfig::DEFAULT_HOST.into(),
598 port: HttpConfig::DEFAULT_PORT,
599 allowed_origins: HttpConfig::default_allowed_origins(),
600 allowed_hosts: HttpConfig::default_allowed_hosts(),
601 }
602 }
603
604 #[test]
605 fn valid_http_config_passes() {
606 assert!(http_config().validate().is_ok());
607 }
608
609 #[test]
610 fn empty_http_host_rejected() {
611 let config = HttpConfig {
612 host: String::new(),
613 ..http_config()
614 };
615 let errors = config.validate().expect_err("empty host must fail");
616 assert!(errors.iter().any(|e| matches!(e, ConfigError::EmptyHttpHost)));
617 }
618
619 #[test]
620 fn whitespace_http_host_rejected() {
621 let config = HttpConfig {
622 host: " ".into(),
623 ..http_config()
624 };
625 let errors = config.validate().expect_err("whitespace host must fail");
626 assert!(errors.iter().any(|e| matches!(e, ConfigError::EmptyHttpHost)));
627 }
628
629 #[test]
630 fn debug_includes_query_timeout() {
631 let config = Config {
632 database: DatabaseConfig {
633 query_timeout: Some(30),
634 ..db_config(DatabaseBackend::Mysql)
635 },
636 ..base_config(DatabaseBackend::Mysql)
637 };
638 let debug = format!("{config:?}");
639 assert!(
640 debug.contains("query_timeout: Some(30)"),
641 "expected query_timeout in debug output: {debug}"
642 );
643 }
644}