config: implement loading, defaults, masking, and validation
- Config::load / load_with read the TOML file; port fields accept either a TOML int or a string (real configs use both) - empty strings resolve to defaults: db 3306, web 8080, $HOME/.config/rpn state dir, <state>/backups backup dir; an explicit state dir steers the backup default - show() masks the password; validate_database() enforces host/user/name for commands that touch the database - resolve_config_path() implements the documented lookup order
This commit is contained in:
@@ -8,6 +8,267 @@
|
||||
//! exists (database port 3306, web port 8080, state dir ~/.config/rpn,
|
||||
//! backup dir <state 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<u16, ConfigError> {
|
||||
let s = match self {
|
||||
FlexNum::Int(n) => n.to_string(),
|
||||
FlexNum::Str(s) => s.clone(),
|
||||
};
|
||||
if s.is_empty() {
|
||||
return Ok(default);
|
||||
}
|
||||
s.parse::<u16>().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<Config, ConfigError> {
|
||||
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<Config, ConfigError> {
|
||||
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<Config, ConfigError> {
|
||||
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::*;
|
||||
|
||||
Reference in New Issue
Block a user