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)]
232pub struct Config {
233 pub database: DatabaseConfig,
235
236 pub http: Option<HttpConfig>,
238}
239
240impl Config {
241 pub fn validate(&self) -> Result<(), Vec<ConfigError>> {
247 let mut errors = Vec::new();
248
249 if self.database.backend == DatabaseBackend::Sqlite
250 && self.database.name.as_deref().unwrap_or_default().is_empty()
251 {
252 errors.push(ConfigError::MissingSqliteDbName);
253 }
254
255 if self.database.ssl {
256 for (name, path) in [
257 ("DB_SSL_CA", &self.database.ssl_ca),
258 ("DB_SSL_CERT", &self.database.ssl_cert),
259 ("DB_SSL_KEY", &self.database.ssl_key),
260 ] {
261 if let Some(path) = path
262 && !std::path::Path::new(path).exists()
263 {
264 errors.push(ConfigError::SslCertNotFound(name.into(), path.clone()));
265 }
266 }
267 }
268
269 if errors.is_empty() { Ok(()) } else { Err(errors) }
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 fn db_config(backend: DatabaseBackend) -> DatabaseConfig {
278 DatabaseConfig {
279 backend,
280 port: backend.default_port(),
281 user: backend.default_user().into(),
282 ..DatabaseConfig::default()
283 }
284 }
285
286 fn base_config(backend: DatabaseBackend) -> Config {
287 Config {
288 database: db_config(backend),
289 http: None,
290 }
291 }
292
293 fn mysql_config() -> Config {
294 Config {
295 database: DatabaseConfig {
296 port: 3306,
297 user: "root".into(),
298 password: Some("secret".into()),
299 ..db_config(DatabaseBackend::Mysql)
300 },
301 ..base_config(DatabaseBackend::Mysql)
302 }
303 }
304
305 #[test]
306 fn debug_redacts_password() {
307 let config = Config {
308 database: DatabaseConfig {
309 password: Some("super_secret_password".into()),
310 ..mysql_config().database
311 },
312 ..mysql_config()
313 };
314 let debug_output = format!("{config:?}");
315 assert!(
316 !debug_output.contains("super_secret_password"),
317 "password leaked in debug output: {debug_output}"
318 );
319 assert!(
320 debug_output.contains("[REDACTED]"),
321 "expected [REDACTED] in debug output: {debug_output}"
322 );
323 }
324
325 #[test]
326 fn valid_mysql_config_passes() {
327 assert!(mysql_config().validate().is_ok());
328 }
329
330 #[test]
331 fn valid_postgres_config_passes() {
332 let config = Config {
333 database: DatabaseConfig {
334 user: "pguser".into(),
335 port: 5432,
336 ..db_config(DatabaseBackend::Postgres)
337 },
338 ..base_config(DatabaseBackend::Postgres)
339 };
340 assert!(config.validate().is_ok());
341 }
342
343 #[test]
344 fn valid_sqlite_config_passes() {
345 let config = Config {
346 database: DatabaseConfig {
347 name: Some("./test.db".into()),
348 ..db_config(DatabaseBackend::Sqlite)
349 },
350 ..base_config(DatabaseBackend::Sqlite)
351 };
352 assert!(config.validate().is_ok());
353 }
354
355 #[test]
356 fn defaults_resolved_at_construction() {
357 let mysql = base_config(DatabaseBackend::Mysql);
358 assert_eq!(mysql.database.host, "localhost");
359 assert_eq!(mysql.database.port, 3306);
360 assert_eq!(mysql.database.user, "root");
361
362 let pg = base_config(DatabaseBackend::Postgres);
363 assert_eq!(pg.database.port, 5432);
364 assert_eq!(pg.database.user, "postgres");
365
366 let sqlite = base_config(DatabaseBackend::Sqlite);
367 assert_eq!(sqlite.database.port, 0);
368 assert_eq!(sqlite.database.user, "");
369 }
370
371 #[test]
372 fn explicit_values_override_defaults() {
373 let config = Config {
374 database: DatabaseConfig {
375 host: "dbserver.example.com".into(),
376 port: 13306,
377 user: "myuser".into(),
378 ..db_config(DatabaseBackend::Mysql)
379 },
380 ..base_config(DatabaseBackend::Mysql)
381 };
382 assert_eq!(config.database.host, "dbserver.example.com");
383 assert_eq!(config.database.port, 13306);
384 assert_eq!(config.database.user, "myuser");
385 }
386
387 #[test]
388 fn mysql_without_user_gets_default() {
389 let config = base_config(DatabaseBackend::Mysql);
390 assert_eq!(config.database.user, "root");
391 assert!(config.validate().is_ok());
392 }
393
394 #[test]
395 fn sqlite_requires_db_name() {
396 let config = base_config(DatabaseBackend::Sqlite);
397 let errors = config.validate().unwrap_err();
398 assert!(errors.iter().any(|e| matches!(e, ConfigError::MissingSqliteDbName)));
399 }
400
401 #[test]
402 fn multiple_errors_accumulated() {
403 let config = Config {
404 database: DatabaseConfig {
405 ssl: true,
406 ssl_ca: Some("/nonexistent/ca.pem".into()),
407 ssl_cert: Some("/nonexistent/cert.pem".into()),
408 ssl_key: Some("/nonexistent/key.pem".into()),
409 ..db_config(DatabaseBackend::Mysql)
410 },
411 ..base_config(DatabaseBackend::Mysql)
412 };
413 let errors = config.validate().unwrap_err();
414 assert!(
415 errors.len() >= 3,
416 "expected at least 3 errors, got {}: {errors:?}",
417 errors.len()
418 );
419 }
420
421 #[test]
422 fn mariadb_backend_is_valid() {
423 let config = base_config(DatabaseBackend::Mariadb);
424 assert!(config.validate().is_ok());
425 }
426}