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 pub connection_timeout: Option<u64>,
128
129 pub query_timeout: Option<u64>,
134}
135
136impl std::fmt::Debug for DatabaseConfig {
137 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138 f.debug_struct("DatabaseConfig")
139 .field("backend", &self.backend)
140 .field("host", &self.host)
141 .field("port", &self.port)
142 .field("user", &self.user)
143 .field("password", &"[REDACTED]")
144 .field("name", &self.name)
145 .field("charset", &self.charset)
146 .field("ssl", &self.ssl)
147 .field("ssl_ca", &self.ssl_ca)
148 .field("ssl_cert", &self.ssl_cert)
149 .field("ssl_key", &self.ssl_key)
150 .field("ssl_verify_cert", &self.ssl_verify_cert)
151 .field("read_only", &self.read_only)
152 .field("max_pool_size", &self.max_pool_size)
153 .field("connection_timeout", &self.connection_timeout)
154 .field("query_timeout", &self.query_timeout)
155 .finish()
156 }
157}
158
159impl DatabaseConfig {
160 pub const DEFAULT_BACKEND: DatabaseBackend = DatabaseBackend::Mysql;
162 pub const DEFAULT_HOST: &'static str = "localhost";
164 pub const DEFAULT_SSL: bool = false;
166 pub const DEFAULT_SSL_VERIFY_CERT: bool = true;
168 pub const DEFAULT_READ_ONLY: bool = true;
170 pub const DEFAULT_MAX_POOL_SIZE: u32 = 5;
172 pub const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 600;
174 pub const DEFAULT_MAX_LIFETIME_SECS: u64 = 1800;
176 pub const DEFAULT_MIN_CONNECTIONS: u32 = 1;
178 pub const DEFAULT_QUERY_TIMEOUT_SECS: u64 = 30;
180}
181
182impl Default for DatabaseConfig {
183 fn default() -> Self {
184 Self {
185 backend: Self::DEFAULT_BACKEND,
186 host: Self::DEFAULT_HOST.into(),
187 port: Self::DEFAULT_BACKEND.default_port(),
188 user: Self::DEFAULT_BACKEND.default_user().into(),
189 password: None,
190 name: None,
191 charset: None,
192 ssl: Self::DEFAULT_SSL,
193 ssl_ca: None,
194 ssl_cert: None,
195 ssl_key: None,
196 ssl_verify_cert: Self::DEFAULT_SSL_VERIFY_CERT,
197 read_only: Self::DEFAULT_READ_ONLY,
198 max_pool_size: Self::DEFAULT_MAX_POOL_SIZE,
199 connection_timeout: None,
200 query_timeout: None,
201 }
202 }
203}
204
205#[derive(Clone, Debug)]
207pub struct HttpConfig {
208 pub host: String,
210
211 pub port: u16,
213
214 pub allowed_origins: Vec<String>,
216
217 pub allowed_hosts: Vec<String>,
219}
220
221impl HttpConfig {
222 pub const DEFAULT_HOST: &'static str = "127.0.0.1";
224 pub const DEFAULT_PORT: u16 = 9001;
226
227 #[must_use]
229 pub fn default_allowed_origins() -> Vec<String> {
230 vec![
231 "http://localhost".into(),
232 "http://127.0.0.1".into(),
233 "https://localhost".into(),
234 "https://127.0.0.1".into(),
235 ]
236 }
237
238 #[must_use]
240 pub fn default_allowed_hosts() -> Vec<String> {
241 vec!["localhost".into(), "127.0.0.1".into()]
242 }
243}
244
245#[derive(Clone, Debug)]
253pub struct Config {
254 pub database: DatabaseConfig,
256
257 pub http: Option<HttpConfig>,
259}
260
261impl Config {
262 pub fn validate(&self) -> Result<(), Vec<ConfigError>> {
268 let mut errors = Vec::new();
269
270 if self.database.backend == DatabaseBackend::Sqlite
271 && self.database.name.as_deref().unwrap_or_default().is_empty()
272 {
273 errors.push(ConfigError::MissingSqliteDbName);
274 }
275
276 if self.database.ssl {
277 for (name, path) in [
278 ("DB_SSL_CA", &self.database.ssl_ca),
279 ("DB_SSL_CERT", &self.database.ssl_cert),
280 ("DB_SSL_KEY", &self.database.ssl_key),
281 ] {
282 if let Some(path) = path
283 && !std::path::Path::new(path).exists()
284 {
285 errors.push(ConfigError::SslCertNotFound(name.into(), path.clone()));
286 }
287 }
288 }
289
290 if errors.is_empty() { Ok(()) } else { Err(errors) }
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 fn db_config(backend: DatabaseBackend) -> DatabaseConfig {
299 DatabaseConfig {
300 backend,
301 port: backend.default_port(),
302 user: backend.default_user().into(),
303 ..DatabaseConfig::default()
304 }
305 }
306
307 fn base_config(backend: DatabaseBackend) -> Config {
308 Config {
309 database: db_config(backend),
310 http: None,
311 }
312 }
313
314 fn mysql_config() -> Config {
315 Config {
316 database: DatabaseConfig {
317 port: 3306,
318 user: "root".into(),
319 password: Some("secret".into()),
320 ..db_config(DatabaseBackend::Mysql)
321 },
322 ..base_config(DatabaseBackend::Mysql)
323 }
324 }
325
326 #[test]
327 fn debug_redacts_password() {
328 let config = Config {
329 database: DatabaseConfig {
330 password: Some("super_secret_password".into()),
331 ..mysql_config().database
332 },
333 ..mysql_config()
334 };
335 let debug_output = format!("{config:?}");
336 assert!(
337 !debug_output.contains("super_secret_password"),
338 "password leaked in debug output: {debug_output}"
339 );
340 assert!(
341 debug_output.contains("[REDACTED]"),
342 "expected [REDACTED] in debug output: {debug_output}"
343 );
344 }
345
346 #[test]
347 fn valid_mysql_config_passes() {
348 assert!(mysql_config().validate().is_ok());
349 }
350
351 #[test]
352 fn valid_postgres_config_passes() {
353 let config = Config {
354 database: DatabaseConfig {
355 user: "pguser".into(),
356 port: 5432,
357 ..db_config(DatabaseBackend::Postgres)
358 },
359 ..base_config(DatabaseBackend::Postgres)
360 };
361 assert!(config.validate().is_ok());
362 }
363
364 #[test]
365 fn valid_sqlite_config_passes() {
366 let config = Config {
367 database: DatabaseConfig {
368 name: Some("./test.db".into()),
369 ..db_config(DatabaseBackend::Sqlite)
370 },
371 ..base_config(DatabaseBackend::Sqlite)
372 };
373 assert!(config.validate().is_ok());
374 }
375
376 #[test]
377 fn defaults_resolved_at_construction() {
378 let mysql = base_config(DatabaseBackend::Mysql);
379 assert_eq!(mysql.database.host, "localhost");
380 assert_eq!(mysql.database.port, 3306);
381 assert_eq!(mysql.database.user, "root");
382
383 let pg = base_config(DatabaseBackend::Postgres);
384 assert_eq!(pg.database.port, 5432);
385 assert_eq!(pg.database.user, "postgres");
386
387 let sqlite = base_config(DatabaseBackend::Sqlite);
388 assert_eq!(sqlite.database.port, 0);
389 assert_eq!(sqlite.database.user, "");
390 }
391
392 #[test]
393 fn explicit_values_override_defaults() {
394 let config = Config {
395 database: DatabaseConfig {
396 host: "dbserver.example.com".into(),
397 port: 13306,
398 user: "myuser".into(),
399 ..db_config(DatabaseBackend::Mysql)
400 },
401 ..base_config(DatabaseBackend::Mysql)
402 };
403 assert_eq!(config.database.host, "dbserver.example.com");
404 assert_eq!(config.database.port, 13306);
405 assert_eq!(config.database.user, "myuser");
406 }
407
408 #[test]
409 fn mysql_without_user_gets_default() {
410 let config = base_config(DatabaseBackend::Mysql);
411 assert_eq!(config.database.user, "root");
412 assert!(config.validate().is_ok());
413 }
414
415 #[test]
416 fn sqlite_requires_db_name() {
417 let config = base_config(DatabaseBackend::Sqlite);
418 let errors = config.validate().unwrap_err();
419 assert!(errors.iter().any(|e| matches!(e, ConfigError::MissingSqliteDbName)));
420 }
421
422 #[test]
423 fn multiple_errors_accumulated() {
424 let config = Config {
425 database: DatabaseConfig {
426 ssl: true,
427 ssl_ca: Some("/nonexistent/ca.pem".into()),
428 ssl_cert: Some("/nonexistent/cert.pem".into()),
429 ssl_key: Some("/nonexistent/key.pem".into()),
430 ..db_config(DatabaseBackend::Mysql)
431 },
432 ..base_config(DatabaseBackend::Mysql)
433 };
434 let errors = config.validate().unwrap_err();
435 assert!(
436 errors.len() >= 3,
437 "expected at least 3 errors, got {}: {errors:?}",
438 errors.len()
439 );
440 }
441
442 #[test]
443 fn mariadb_backend_is_valid() {
444 let config = base_config(DatabaseBackend::Mariadb);
445 assert!(config.validate().is_ok());
446 }
447
448 #[test]
449 fn query_timeout_default_is_none() {
450 let config = DatabaseConfig::default();
451 assert!(config.query_timeout.is_none());
452 }
453
454 #[test]
455 fn debug_includes_query_timeout() {
456 let config = Config {
457 database: DatabaseConfig {
458 query_timeout: Some(30),
459 ..db_config(DatabaseBackend::Mysql)
460 },
461 ..base_config(DatabaseBackend::Mysql)
462 };
463 let debug = format!("{config:?}");
464 assert!(
465 debug.contains("query_timeout: Some(30)"),
466 "expected query_timeout in debug output: {debug}"
467 );
468 }
469}