use clap::Parser; use serde::{Deserialize, Serialize}; use url::Url; use crate::WardenError; #[derive(Parser, Clone, Deserialize, Serialize, Debug)] #[serde(rename_all = "kebab-case")] pub struct Database { /// Full database URL (if provided, overrides individual components) #[arg(long, env = "DATABASE_URL")] pub database_url: Option, #[arg(long, env = "DB_USER")] /// Database username #[serde(rename = "username")] pub database_username: Option, /// Database password #[arg(long, env = "DB_PASSWORD")] #[serde(rename = "password")] pub database_password: Option, /// Database host #[arg(long, env = "DB_HOST")] #[serde(rename = "host")] pub database_host: Option, /// Database port #[arg(long, env = "DB_PORT")] #[serde(rename = "port")] pub database_port: Option, /// Database name #[arg(long, env = "DB_NAME")] #[serde(rename = "name")] pub database_name: Option, /// Database pool size #[arg(long, env = "DATABASE_POOL_SIZE")] #[serde(rename = "pool-size")] pub database_pool_size: Option, } impl Default for Database { fn default() -> Self { Self { database_url: Default::default(), database_username: Some(String::from("postgres")), database_password: Some(String::from("password")), database_host: Some(String::from("localhost")), database_port: Some(5432), database_name: Some(String::from("warden")), database_pool_size: Some(10), } } } impl Database { pub fn merge(cli: &Self, file: &Self) -> Result { let pool_size = cli .database_pool_size .or(file.database_pool_size) .unwrap_or(10); if let Some(url) = cli .database_url .clone() .or_else(|| file.database_url.clone()) { return Ok(Self { database_url: Some(url), database_pool_size: Some(pool_size), ..Default::default() }); } let host = cli .database_host .as_deref() .or(file.database_host.as_deref()) .unwrap_or("localhost"); let mut u = Url::parse(&format!("postgresql://{}", host))?; let user = cli .database_username .as_deref() .or(file.database_username.as_deref()); let pass = cli .database_password .as_deref() .or(file.database_password.as_deref()); let port = cli.database_port.or(file.database_port); let name = cli .database_name .as_deref() .or(file.database_name.as_deref()); if let Some(user) = user { u.set_username(user).ok(); } if let Some(pass) = pass { u.set_password(Some(pass)).ok(); } if let Some(port) = port { u.set_port(Some(port)).ok(); } if let Some(name) = name { u.set_path(name); } Ok(Self { database_url: Some(u), database_pool_size: Some(pool_size), // Carry over the other fields for record-keeping database_host: Some(host.to_string()), database_username: user.map(String::from), database_password: pass.map(String::from), database_port: port, database_name: name.map(String::from), }) } pub fn get_url(&self) -> Result { if let Some(ref url) = self.database_url { return Ok(url.clone()); } let host = "localhost".to_owned(); let host = self.database_host.as_ref().unwrap_or(&host); let mut url = Url::parse(&format!("postgres://{host}"))?; if let Some(ref u) = self.database_username { url.set_username(u).ok(); } if let Some(ref p) = self.database_password { url.set_password(Some(p)).ok(); } url.set_port(self.database_port).ok(); if let Some(ref name) = self.database_name { url.set_path(name); } Ok(url) } } #[cfg(test)] mod tests { use super::*; use url::Url; /// Helper to create a "naked" Database struct with all Nones /// Useful for testing merge logic without Default values interfering fn empty_db() -> Database { Database { database_url: None, database_username: None, database_password: None, database_host: None, database_port: None, database_name: None, database_pool_size: None, } } #[test] fn test_get_url_from_components() { let db = Database { database_host: Some("127.0.0.1".to_string()), database_username: Some("admin".to_string()), database_password: Some("secret".to_string()), database_port: Some(5432), database_name: Some("testdb".to_string()), ..empty_db() }; let url = db.get_url().expect("Should parse URL"); // Note: get_url uses "postgres://" scheme assert_eq!( url.as_str(), "postgres://admin:secret@127.0.0.1:5432/testdb" ); } #[test] fn test_merge_cli_overrides_file() { let mut file_config = empty_db(); file_config.database_host = Some("file-host".to_string()); file_config.database_port = Some(1111); let mut cli_config = empty_db(); cli_config.database_host = Some("cli-host".to_string()); // database_port is None in CLI let merged = Database::merge(&cli_config, &file_config).expect("Merge failed"); let url = merged.database_url.unwrap(); // CLI host should win assert_eq!(url.host_str(), Some("cli-host")); // File port should win because CLI was None assert_eq!(url.port(), Some(1111)); } #[test] fn test_merge_url_override_wins_all() { let mut file_config = empty_db(); file_config.database_host = Some("local-host".to_string()); let mut cli_config = empty_db(); let expected_url = "postgresql://remote-host:9999/prod"; cli_config.database_url = Some(Url::parse(expected_url).unwrap()); let merged = Database::merge(&cli_config, &file_config).expect("Merge failed"); assert_eq!(merged.database_url.unwrap().as_str(), expected_url); } #[test] fn test_merge_pool_size_logic() { let mut file_config = empty_db(); file_config.database_pool_size = Some(50); let cli_config = empty_db(); // pool_size is None let merged = Database::merge(&cli_config, &file_config).expect("Merge failed"); // Should take file value if CLI is None assert_eq!(merged.database_pool_size, Some(50)); } #[test] fn test_default_trait_implementation() { let db = Database::default(); assert_eq!(db.database_port, Some(5432)); assert_eq!(db.database_username, Some("postgres".to_string())); assert_eq!(db.database_host, Some("localhost".to_string())); } }