use serde::Deserialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Deserialize, Default)]
struct FileConfig {
server: Option<ServerSection>,
auth: Option<AuthSection>,
tls: Option<TlsSection>,
}
#[derive(Debug, Deserialize, Default)]
struct ServerSection {
host: Option<String>,
port: Option<u16>,
data_dir: Option<String>,
shutdown_timeout_secs: Option<u64>,
}
#[derive(Debug, Deserialize, Default)]
struct AuthSection {
api_keys: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, Default)]
struct TlsSection {
cert: Option<String>,
key: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
pub data_dir: String,
pub api_keys: Vec<String>,
pub tls_cert: Option<String>,
pub tls_key: Option<String>,
pub shutdown_timeout_secs: u64,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: "127.0.0.1".to_string(),
port: 8080,
data_dir: "./velesdb_data".to_string(),
api_keys: Vec::new(),
tls_cert: None,
tls_key: None,
shutdown_timeout_secs: 30,
}
}
}
impl ServerConfig {
pub fn load(cli: CliOverrides) -> anyhow::Result<Self> {
let defaults = Self::default();
let file_cfg = load_toml_file(&cli.config_path)?;
Ok(Self::merge(defaults, file_cfg, cli))
}
fn merge(defaults: Self, file: FileConfig, cli: CliOverrides) -> Self {
let server = file.server.unwrap_or_default();
let auth = file.auth.unwrap_or_default();
let tls = file.tls.unwrap_or_default();
let host = server.host.unwrap_or(defaults.host);
let port = server.port.unwrap_or(defaults.port);
let data_dir = server.data_dir.unwrap_or(defaults.data_dir);
let shutdown_timeout_secs = server
.shutdown_timeout_secs
.unwrap_or(defaults.shutdown_timeout_secs);
let api_keys = auth.api_keys.unwrap_or(defaults.api_keys);
let tls_cert = tls.cert.or(defaults.tls_cert);
let tls_key = tls.key.or(defaults.tls_key);
let host = cli.host.unwrap_or(host);
let port = cli.port.unwrap_or(port);
let data_dir = cli.data_dir.unwrap_or(data_dir);
let api_keys = cli.api_keys.unwrap_or(api_keys);
let tls_cert = cli.tls_cert.or(tls_cert);
let tls_key = cli.tls_key.or(tls_key);
Self {
host,
port,
data_dir,
api_keys,
tls_cert,
tls_key,
shutdown_timeout_secs,
}
}
pub fn validate(&self) -> anyhow::Result<()> {
if self.port == 0 {
anyhow::bail!("invalid port: 0 is not allowed");
}
if self.data_dir.is_empty() {
anyhow::bail!("data_dir must not be empty");
}
match (&self.tls_cert, &self.tls_key) {
(Some(_), None) => {
anyhow::bail!("tls_cert is set but tls_key is missing");
}
(None, Some(_)) => {
anyhow::bail!("tls_key is set but tls_cert is missing");
}
(Some(cert), Some(key)) => {
if !Path::new(cert).exists() {
anyhow::bail!("TLS cert file not found: {cert}");
}
if !Path::new(key).exists() {
anyhow::bail!("TLS key file not found: {key}");
}
}
(None, None) => {}
}
Ok(())
}
pub fn auth_enabled(&self) -> bool {
!self.api_keys.is_empty()
}
pub fn tls_enabled(&self) -> bool {
self.tls_cert.is_some() && self.tls_key.is_some()
}
}
#[derive(Debug, Default)]
pub struct CliOverrides {
pub config_path: Option<PathBuf>,
pub host: Option<String>,
pub port: Option<u16>,
pub data_dir: Option<String>,
pub api_keys: Option<Vec<String>>,
pub tls_cert: Option<String>,
pub tls_key: Option<String>,
}
fn load_toml_file(path: &Option<PathBuf>) -> anyhow::Result<FileConfig> {
let candidate = match path {
Some(p) => {
if !p.exists() {
anyhow::bail!("config file not found: {}", p.display());
}
p.clone()
}
None => {
let default_path = PathBuf::from("velesdb.toml");
if !default_path.exists() {
return Ok(FileConfig::default());
}
default_path
}
};
let contents = std::fs::read_to_string(&candidate)
.map_err(|e| anyhow::anyhow!("failed to read config file {}: {e}", candidate.display()))?;
let cfg: FileConfig = toml::from_str(&contents)
.map_err(|e| anyhow::anyhow!("failed to parse config file {}: {e}", candidate.display()))?;
Ok(cfg)
}
pub fn parse_api_keys_env() -> Option<Vec<String>> {
let val = std::env::var("VELESDB_API_KEYS").ok()?;
let keys: Vec<String> = val
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if keys.is_empty() {
None
} else {
Some(keys)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_defaults() {
let cfg = ServerConfig::default();
assert_eq!(cfg.host, "127.0.0.1");
assert_eq!(cfg.port, 8080);
assert_eq!(cfg.data_dir, "./velesdb_data");
assert!(cfg.api_keys.is_empty());
assert!(cfg.tls_cert.is_none());
assert!(cfg.tls_key.is_none());
assert_eq!(cfg.shutdown_timeout_secs, 30);
assert!(!cfg.auth_enabled());
assert!(!cfg.tls_enabled());
}
#[test]
fn test_toml_overrides_defaults() {
let toml_content = r#"
[server]
host = "0.0.0.0"
port = 9090
data_dir = "/var/velesdb"
shutdown_timeout_secs = 60
[auth]
api_keys = ["key-alpha", "key-beta"]
[tls]
cert = "/etc/ssl/cert.pem"
key = "/etc/ssl/key.pem"
"#;
let file_cfg: FileConfig = toml::from_str(toml_content).unwrap();
let cli = CliOverrides::default();
let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
assert_eq!(cfg.host, "0.0.0.0");
assert_eq!(cfg.port, 9090);
assert_eq!(cfg.data_dir, "/var/velesdb");
assert_eq!(cfg.shutdown_timeout_secs, 60);
assert_eq!(cfg.api_keys, vec!["key-alpha", "key-beta"]);
assert_eq!(cfg.tls_cert.as_deref(), Some("/etc/ssl/cert.pem"));
assert_eq!(cfg.tls_key.as_deref(), Some("/etc/ssl/key.pem"));
assert!(cfg.auth_enabled());
assert!(cfg.tls_enabled());
}
#[test]
fn test_cli_overrides_toml() {
let toml_content = r#"
[server]
host = "0.0.0.0"
port = 9090
"#;
let file_cfg: FileConfig = toml::from_str(toml_content).unwrap();
let cli = CliOverrides {
port: Some(3000),
host: Some("10.0.0.1".to_string()),
..Default::default()
};
let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
assert_eq!(cfg.host, "10.0.0.1");
assert_eq!(cfg.port, 3000);
assert_eq!(cfg.data_dir, "./velesdb_data");
}
#[test]
fn test_partial_toml_uses_defaults_for_missing() {
let toml_content = r#"
[server]
port = 4000
"#;
let file_cfg: FileConfig = toml::from_str(toml_content).unwrap();
let cli = CliOverrides::default();
let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
assert_eq!(cfg.port, 4000);
assert_eq!(cfg.host, "127.0.0.1"); assert_eq!(cfg.data_dir, "./velesdb_data"); }
#[test]
fn test_empty_toml_uses_all_defaults() {
let file_cfg: FileConfig = toml::from_str("").unwrap();
let cli = CliOverrides::default();
let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
assert_eq!(cfg, ServerConfig::default());
}
#[test]
fn test_validate_port_zero_rejected() {
let cfg = ServerConfig {
port: 0,
..ServerConfig::default()
};
let err = cfg.validate().unwrap_err();
assert!(err.to_string().contains("port"));
}
#[test]
fn test_validate_empty_data_dir_rejected() {
let cfg = ServerConfig {
data_dir: String::new(),
..ServerConfig::default()
};
let err = cfg.validate().unwrap_err();
assert!(err.to_string().contains("data_dir"));
}
#[test]
fn test_validate_tls_cert_without_key() {
let cfg = ServerConfig {
tls_cert: Some("/tmp/cert.pem".to_string()),
..ServerConfig::default()
};
let err = cfg.validate().unwrap_err();
assert!(err.to_string().contains("tls_key is missing"));
}
#[test]
fn test_validate_tls_key_without_cert() {
let cfg = ServerConfig {
tls_key: Some("/tmp/key.pem".to_string()),
..ServerConfig::default()
};
let err = cfg.validate().unwrap_err();
assert!(err.to_string().contains("tls_cert is missing"));
}
#[test]
fn test_validate_tls_missing_cert_file() {
let cfg = ServerConfig {
tls_cert: Some("/nonexistent/cert.pem".to_string()),
tls_key: Some("/nonexistent/key.pem".to_string()),
..ServerConfig::default()
};
let err = cfg.validate().unwrap_err();
assert!(err.to_string().contains("cert file not found"));
}
#[test]
fn test_validate_tls_valid_files() {
let dir = tempfile::tempdir().unwrap();
let cert_path = dir.path().join("cert.pem");
let key_path = dir.path().join("key.pem");
std::fs::File::create(&cert_path)
.unwrap()
.write_all(b"cert")
.unwrap();
std::fs::File::create(&key_path)
.unwrap()
.write_all(b"key")
.unwrap();
let cfg = ServerConfig {
tls_cert: Some(cert_path.to_string_lossy().to_string()),
tls_key: Some(key_path.to_string_lossy().to_string()),
..ServerConfig::default()
};
cfg.validate().expect("valid TLS config should pass");
}
#[test]
fn test_parse_api_keys_env() {
let input = "key1, key2 , key3";
let keys: Vec<String> = input
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
assert_eq!(keys, vec!["key1", "key2", "key3"]);
}
#[test]
fn test_load_toml_file_not_found_explicit_path() {
let result = load_toml_file(&Some(PathBuf::from("/nonexistent/velesdb.toml")));
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("config file not found"));
}
#[test]
fn test_load_toml_file_no_default_returns_empty() {
let result = load_toml_file(&None);
assert!(result.is_ok());
}
#[test]
fn test_full_priority_chain() {
let toml_content = r#"
[server]
port = 9090
host = "0.0.0.0"
data_dir = "/toml/data"
"#;
let file_cfg: FileConfig = toml::from_str(toml_content).unwrap();
let cli = CliOverrides {
port: Some(3000),
..Default::default()
};
let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
assert_eq!(cfg.port, 3000); assert_eq!(cfg.host, "0.0.0.0"); assert_eq!(cfg.data_dir, "/toml/data"); }
}