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,
|
//! exists (database port 3306, web port 8080, state dir ~/.config/rpn,
|
||||||
//! backup dir <state dir>/backups).
|
//! 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
Reference in New Issue
Block a user