diff --git a/Application/src/core/src/config.rs b/Application/src/core/src/config.rs index 8843641..cc611cf 100644 --- a/Application/src/core/src/config.rs +++ b/Application/src/core/src/config.rs @@ -8,6 +8,267 @@ //! exists (database port 3306, web port 8080, state dir ~/.config/rpn, //! backup dir /backups). +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +/// Errors from config lookup, parsing, or validation. +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("config file not found: {0}")] + NotFound(PathBuf), + #[error("failed to read config file {path}: {source}")] + Io { path: PathBuf, #[source] source: std::io::Error }, + #[error("failed to parse config file {path}: {source}")] + Parse { path: PathBuf, #[source] source: toml::de::Error }, + #[error("invalid config: {field} — {message}")] + Invalid { field: String, message: String }, +} + +/// Effective (defaults applied) database settings. +#[derive(Debug, Clone)] +pub struct DatabaseConfig { + pub host: String, + pub port: u16, + pub user: String, + pub password: String, + pub name: String, +} + +/// Effective (defaults applied) web server settings. +#[derive(Debug, Clone)] +pub struct WebConfig { + pub port: u16, + pub fqdn: String, +} + +/// Effective (defaults applied) daemon state directory. +#[derive(Debug, Clone)] +pub struct StateConfig { + pub dir: String, +} + +/// Effective (defaults applied) backup directory. +#[derive(Debug, Clone)] +pub struct BackupConfig { + pub dir: String, +} + +/// Fully resolved configuration: every field holds its effective value. +#[derive(Debug, Clone)] +pub struct Config { + pub database: DatabaseConfig, + pub web: WebConfig, + pub state: StateConfig, + pub backup: BackupConfig, +} + +/// A config field that may be written as a TOML int or a string. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum FlexNum { + Int(i64), + Str(String), +} + +impl Default for FlexNum { + fn default() -> Self { + FlexNum::Str(String::new()) + } +} + +impl FlexNum { + fn as_port(&self, field: &str, default: u16) -> Result { + let s = match self { + FlexNum::Int(n) => n.to_string(), + FlexNum::Str(s) => s.clone(), + }; + if s.is_empty() { + return Ok(default); + } + s.parse::().map_err(|_| ConfigError::Invalid { + field: field.to_string(), + message: format!("\"{s}\" is not a valid port number"), + }) + } +} + +#[derive(Deserialize, Default)] +struct RawDatabase { + #[serde(default)] + host: String, + #[serde(default)] + port: FlexNum, + #[serde(default)] + user: String, + #[serde(default)] + password: String, + #[serde(default)] + name: String, +} + +#[derive(Deserialize, Default)] +struct RawWeb { + #[serde(default)] + port: FlexNum, + #[serde(default)] + fqdn: String, +} + +#[derive(Deserialize, Default)] +struct RawState { + #[serde(default)] + dir: String, +} + +#[derive(Deserialize, Default)] +struct RawBackup { + #[serde(default)] + dir: String, +} + +#[derive(Deserialize, Default)] +struct RawConfig { + #[serde(default)] + database: RawDatabase, + #[serde(default)] + web: RawWeb, + #[serde(default)] + state: RawState, + #[serde(default)] + backup: RawBackup, +} + +pub const DEFAULT_DATABASE_PORT: u16 = 3306; +pub const DEFAULT_WEB_PORT: u16 = 8080; + +impl Config { + /// Load from `path`, deriving the default state dir from `$HOME`. + pub fn load(path: &Path) -> Result { + let home = std::env::var_os("HOME").map(PathBuf::from).ok_or_else(|| { + ConfigError::Invalid { + field: "state.dir".to_string(), + message: "$HOME is unset; set [state].dir explicitly".to_string(), + } + })?; + Self::load_with(path, &home) + } + + /// Load from `path` with an explicit home directory (test seam). + pub fn load_with(path: &Path, home: &Path) -> Result { + let text = std::fs::read_to_string(path).map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + ConfigError::NotFound(path.to_path_buf()) + } else { + ConfigError::Io { path: path.to_path_buf(), source: e } + } + })?; + let raw: RawConfig = + toml::from_str(&text).map_err(|e| ConfigError::Parse { path: path.to_path_buf(), source: e })?; + Self::from_raw(raw, home) + } + + fn from_raw(raw: RawConfig, home: &Path) -> Result { + let state = if raw.state.dir.is_empty() { + home.join(".config").join("rpn").to_string_lossy().into_owned() + } else { + raw.state.dir.clone() + }; + let backup = if raw.backup.dir.is_empty() { + PathBuf::from(state.clone()) + .join("backups") + .to_string_lossy() + .into_owned() + } else { + raw.backup.dir.clone() + }; + + Ok(Config { + database: DatabaseConfig { + host: raw.database.host, + port: raw.database.port.as_port("database.port", DEFAULT_DATABASE_PORT)?, + user: raw.database.user, + password: raw.database.password, + name: raw.database.name, + }, + web: WebConfig { + port: raw.web.port.as_port("web.port", DEFAULT_WEB_PORT)?, + fqdn: raw.web.fqdn, + }, + state: StateConfig { dir: state }, + backup: BackupConfig { dir: backup }, + }) + } + + /// Human-readable rendering with the password masked. + pub fn show(&self) -> String { + let mask = |s: &str| if s.is_empty() { String::new() } else { "***".to_string() }; + format!( + "[database]\n\ + host = \"{}\"\n\ + port = {}\n\ + user = \"{}\"\n\ + password = \"{}\"\n\ + name = \"{}\"\n\ + [web]\n\ + port = {}\n\ + fqdn = \"{}\"\n\ + [state]\n\ + dir = \"{}\"\n\ + [backup]\n\ + dir = \"{}\"\n", + self.database.host, + self.database.port, + self.database.user, + mask(&self.database.password), + self.database.name, + self.web.port, + self.web.fqdn, + self.state.dir, + self.backup.dir + ) + } + + /// Fail if the database section cannot be used (host/user/name required). + pub fn validate_database(&self) -> Result<(), ConfigError> { + let missing: Vec<&str> = [ + (self.database.host.is_empty(), "host"), + (self.database.user.is_empty(), "user"), + (self.database.name.is_empty(), "name"), + ] + .into_iter() + .filter(|(is_missing, _)| *is_missing) + .map(|(_, field)| field) + .collect(); + if missing.is_empty() { + return Ok(()); + } + Err(ConfigError::Invalid { + field: "database".to_string(), + message: format!("missing required field(s): {}", missing.join(", ")), + }) + } +} + +/// Resolve the config file path from the global CLI options. +pub fn resolve_config_path( + flag: Option<&Path>, + env_value: Option<&str>, + cwd: &Path, + env_name: Option<&str>, +) -> PathBuf { + if let Some(p) = flag { + return p.to_path_buf(); + } + if let Some(e) = env_value { + return PathBuf::from(e); + } + match env_name { + Some(name) => cwd.join(format!("config.{name}.toml")), + None => cwd.join("config.toml"), + } +} + #[cfg(test)] mod tests { use super::*;