use serde::Deserialize; use std::env; use std::fs; use std::path::PathBuf; pub const NX9_BZOD_DATA_DIR_ENV: &str = "NX9_BZOD_DATA_DIR"; #[derive(Debug, Clone, PartialEq, Eq)] pub enum ConfigError { MissingDataDir, InvalidConfig(String), } impl std::fmt::Display for ConfigError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::MissingDataDir => write!( f, "data directory is not configured; set NX9_BZOD_DATA_DIR or use --data-dir=" ), Self::InvalidConfig(msg) => write!(f, "configuration error: {msg}"), } } } impl std::error::Error for ConfigError {} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DataDirSource { Cli, Env, ConfigFile, } impl std::fmt::Display for DataDirSource { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Cli => write!(f, "CLI"), Self::Env => write!(f, "NX9_BZOD_DATA_DIR"), Self::ConfigFile => write!(f, "config.toml"), } } } #[derive(Clone, Debug)] pub struct Config { pub host: String, pub port: u16, pub data_dir: PathBuf, pub admin_username: String, pub bootstrap_password_sha256: String, pub session_secret: String, pub cookie_secure: bool, pub data_retention_days: Option, pub link_check_interval_mins: u64, pub aggregation_interval_mins: u64, pub backup_enabled: bool, pub backup_interval_mins: u64, pub backup_dir: PathBuf, pub base_url: Option, } #[derive(Deserialize, Default)] struct TomlConfig { host: Option, port: Option, data_dir: Option, admin_username: Option, bootstrap_password_sha256: Option, session_secret: Option, cookie_secure: Option, data_retention_days: Option, link_check_interval_mins: Option, aggregation_interval_mins: Option, backup: Option, base_url: Option, } #[derive(Deserialize, Default)] struct TomlBackupConfig { enabled: Option, interval_mins: Option, out_dir: Option, } impl Config { pub fn load() -> Result { Self::load_with_cli(None::<&std::path::Path>) } pub fn load_with_cli>( cli_data_dir: Option

, ) -> Result { Self::load_from_sources(cli_data_dir, true) } pub fn load_from_sources>( cli_data_dir: Option

, load_dotenv: bool, ) -> Result { // 1. Built-in defaults (no implicit data_dir default) let mut host = "0.0.0.0".to_string(); let mut port = 8080u16; let mut admin_username = "admin".to_string(); let mut bootstrap_password_sha256 = "".to_string(); let mut session_secret = "bzod-default-session-secret-change-me-in-production-please-do-it".to_string(); let mut cookie_secure = true; let mut data_retention_days = None; let mut link_check_interval_mins = 60u64; let mut aggregation_interval_mins = 60u64; let mut backup_enabled = false; let mut backup_interval_mins = 1440u64; // Default: once per day let mut backup_dir = PathBuf::from("./backups"); let mut base_url = None; let mut resolved_data_dir: Option = None; let mut data_dir_source: Option = None; // 2. Load bzod.toml / config.toml if it exists let possible_tomls = [ "bzod.toml", "config/bzod.toml", "config.toml", "config/config.toml", ]; for toml_path in possible_tomls { if let Ok(toml_content) = fs::read_to_string(toml_path) { if let Ok(toml_config) = toml::from_str::(&toml_content) { if let Some(h) = toml_config.host { host = h; } if let Some(p) = toml_config.port { port = p; } if let Some(d) = toml_config.data_dir { if !d.trim().is_empty() { resolved_data_dir = Some(PathBuf::from(d)); data_dir_source = Some(DataDirSource::ConfigFile); } } if let Some(u) = toml_config.admin_username { admin_username = u; } if let Some(s) = toml_config.bootstrap_password_sha256 { bootstrap_password_sha256 = s; } if let Some(sec) = toml_config.session_secret { session_secret = sec; } if let Some(c) = toml_config.cookie_secure { cookie_secure = c; } if let Some(ret) = toml_config.data_retention_days { if ret.eq_ignore_ascii_case("unlimited") { data_retention_days = None; } else if let Ok(parsed) = ret.parse::() { data_retention_days = Some(parsed); } } if let Some(lc) = toml_config.link_check_interval_mins { link_check_interval_mins = lc; } if let Some(ag) = toml_config.aggregation_interval_mins { aggregation_interval_mins = ag; } if let Some(b) = toml_config.backup { if let Some(be) = b.enabled { backup_enabled = be; } if let Some(bi) = b.interval_mins { backup_interval_mins = bi; } if let Some(bo) = b.out_dir { backup_dir = PathBuf::from(bo); } } if let Some(bu) = toml_config.base_url { base_url = Some(bu); } break; } } } // 3. Load .env if present if load_dotenv { let _ = dotenvy::dotenv(); if fs::metadata("config/.env").is_ok() { let _ = dotenvy::from_path("config/.env"); } } // 4. Load from Environment Variables (taking precedence over config file) if let Ok(h) = env::var("HOST") { host = h; } if let Ok(p_str) = env::var("PORT") { if let Ok(p) = p_str.parse::() { port = p; } } if let Ok(d_str) = env::var(NX9_BZOD_DATA_DIR_ENV) { if !d_str.trim().is_empty() { resolved_data_dir = Some(PathBuf::from(d_str)); data_dir_source = Some(DataDirSource::Env); } } if let Ok(u) = env::var("ADMIN_USERNAME") { admin_username = u; } if let Ok(s) = env::var("BOOTSTRAP_PASSWORD_SHA256") { bootstrap_password_sha256 = s; } if let Ok(sec) = env::var("SESSION_SECRET") { session_secret = sec; } if let Ok(c_str) = env::var("COOKIE_SECURE") { if let Ok(c) = c_str.parse::() { cookie_secure = c; } } if let Ok(ret_str) = env::var("DATA_RETENTION_DAYS") { if ret_str.eq_ignore_ascii_case("unlimited") { data_retention_days = None; } else if let Ok(parsed) = ret_str.parse::() { data_retention_days = Some(parsed); } } if let Ok(lc_str) = env::var("LINK_CHECK_INTERVAL_MINS") { if let Ok(lc) = lc_str.parse::() { link_check_interval_mins = lc; } } if let Ok(ag_str) = env::var("AGGREGATION_INTERVAL_MINS") { if let Ok(ag) = ag_str.parse::() { aggregation_interval_mins = ag; } } if let Ok(be_str) = env::var("BACKUP_ENABLED") { if let Ok(be) = be_str.parse::() { backup_enabled = be; } } if let Ok(bi_str) = env::var("BACKUP_INTERVAL_MINS") { if let Ok(bi) = bi_str.parse::() { backup_interval_mins = bi; } } if let Ok(bo_str) = env::var("BACKUP_DIR") { backup_dir = PathBuf::from(bo_str); } if let Ok(bu) = env::var("BASE_URL") { base_url = Some(bu); } // 5. CLI override (highest precedence) if let Some(cli_dir) = cli_data_dir { let path_ref = cli_dir.as_ref(); if !path_ref.as_os_str().is_empty() { resolved_data_dir = Some(path_ref.to_path_buf()); data_dir_source = Some(DataDirSource::Cli); } } let data_dir = match resolved_data_dir { Some(dir) => dir, None => return Err(ConfigError::MissingDataDir), }; if let Some(source) = data_dir_source { tracing::info!("Data directory: {}", data_dir.display()); tracing::info!("Data directory source: {}", source); } Ok(Self { host, port, data_dir, admin_username, bootstrap_password_sha256, session_secret, cookie_secure, data_retention_days, link_check_interval_mins, aggregation_interval_mins, backup_enabled, backup_interval_mins, backup_dir, base_url, }) } }