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:
2026-09-08 18:49:52 +02:00
parent a81dd6f543
commit 9452962c1c
+261
View File
@@ -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::*;