use std::path::Path;
use crate::error::{Error, Result};
use crate::models::Config;
pub fn load_config(path: &Path) -> Result<Config> {
let contents = std::fs::read_to_string(path).map_err(|e| {
Error::Config(format!(
"failed to read config file at {}: {e}",
path.display()
))
})?;
let mut config: Config = toml::from_str(&contents)?;
apply_env_overrides(&mut config);
resolve_reasoning_api_key(&mut config)?;
validate_escalation(&config)?;
Ok(config)
}
fn validate_escalation(config: &Config) -> Result<()> {
if config.escalation.dispatch_interval_seconds == 0 {
return Err(Error::Config(
"[escalation].dispatch_interval_seconds must be a positive integer (got 0)".into(),
));
}
Ok(())
}
fn resolve_reasoning_api_key(config: &mut Config) -> Result<()> {
let var = config.reasoning.api_key_env.trim().to_string();
if var.is_empty() {
if config.reasoning.enabled {
return Err(Error::Config(
"[reasoning].api_key_env is empty but [reasoning].enabled = true".into(),
));
}
return Ok(());
}
match std::env::var(&var) {
Ok(v) if !v.trim().is_empty() => {
config.reasoning.api_key = v.trim().to_string();
Ok(())
}
_ if config.reasoning.enabled => Err(Error::Config(format!(
"reasoning provider enabled but credential env var `{var}` is unset or empty"
))),
_ => Ok(()),
}
}
fn apply_env_overrides(config: &mut Config) {
if let Some(key) = non_empty_env("SERBERO_PRIVATE_KEY") {
config.serbero.private_key = key;
}
if let Some(path) = non_empty_env("SERBERO_DB_PATH") {
config.serbero.db_path = path;
}
if let Some(level) = non_empty_env("SERBERO_LOG") {
config.serbero.log_level = level;
}
}
fn non_empty_env(var: &str) -> Option<String> {
std::env::var(var)
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::SolverPermission;
use std::io::Write;
use std::sync::Mutex;
static ENV_GUARD: Mutex<()> = Mutex::new(());
fn clear_env() {
std::env::remove_var("SERBERO_PRIVATE_KEY");
std::env::remove_var("SERBERO_DB_PATH");
std::env::remove_var("SERBERO_LOG");
}
struct EnvLock<'a> {
_guard: std::sync::MutexGuard<'a, ()>,
}
impl<'a> EnvLock<'a> {
fn new() -> Self {
let guard = match ENV_GUARD.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
clear_env();
Self { _guard: guard }
}
}
impl Drop for EnvLock<'_> {
fn drop(&mut self) {
clear_env();
}
}
fn write_tmp(contents: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(contents.as_bytes()).unwrap();
f
}
const VALID_CONFIG: &str = r#"
[serbero]
private_key = "aa11"
db_path = "serbero.db"
log_level = "info"
[mostro]
pubkey = "bb22"
[[relays]]
url = "wss://relay.example.com"
[[solvers]]
pubkey = "cc33"
permission = "read"
[[solvers]]
pubkey = "dd44"
permission = "write"
[timeouts]
renotification_seconds = 120
renotification_check_interval_seconds = 30
"#;
#[test]
fn parses_full_valid_config() {
let _lock = EnvLock::new();
let f = write_tmp(VALID_CONFIG);
let cfg = load_config(f.path()).expect("parse");
assert_eq!(cfg.serbero.private_key, "aa11");
assert_eq!(cfg.serbero.db_path, "serbero.db");
assert_eq!(cfg.mostro.pubkey, "bb22");
assert_eq!(cfg.relays.len(), 1);
assert_eq!(cfg.solvers.len(), 2);
assert_eq!(cfg.solvers[0].permission, SolverPermission::Read);
assert_eq!(cfg.solvers[1].permission, SolverPermission::Write);
assert_eq!(cfg.timeouts.renotification_seconds, 120);
assert_eq!(cfg.timeouts.renotification_check_interval_seconds, 30);
}
#[test]
fn env_overrides_apply() {
let _lock = EnvLock::new();
let f = write_tmp(VALID_CONFIG);
std::env::set_var("SERBERO_PRIVATE_KEY", "env_override_key");
std::env::set_var("SERBERO_DB_PATH", "/tmp/env.db");
std::env::set_var("SERBERO_LOG", "debug");
let cfg = load_config(f.path()).expect("parse");
assert_eq!(cfg.serbero.private_key, "env_override_key");
assert_eq!(cfg.serbero.db_path, "/tmp/env.db");
assert_eq!(cfg.serbero.log_level, "debug");
}
#[test]
fn env_overrides_are_trimmed() {
let _lock = EnvLock::new();
let f = write_tmp(VALID_CONFIG);
std::env::set_var("SERBERO_PRIVATE_KEY", " abcd1234 ");
std::env::set_var("SERBERO_LOG", " debug ");
let cfg = load_config(f.path()).expect("parse");
assert_eq!(cfg.serbero.private_key, "abcd1234");
assert_eq!(cfg.serbero.log_level, "debug");
}
#[test]
fn malformed_toml_yields_config_error() {
let f = write_tmp("not = valid\n[unclosed");
let err = load_config(f.path()).unwrap_err();
assert!(matches!(err, Error::TomlParse(_)));
}
#[test]
fn missing_file_yields_config_error() {
let err = load_config(Path::new("/no/such/path/config.toml")).unwrap_err();
assert!(matches!(err, Error::Config(_)));
}
#[test]
fn empty_env_vars_are_ignored() {
let _lock = EnvLock::new();
let f = write_tmp(VALID_CONFIG);
std::env::set_var("SERBERO_PRIVATE_KEY", "");
std::env::set_var("SERBERO_DB_PATH", " ");
std::env::set_var("SERBERO_LOG", "");
let cfg = load_config(f.path()).expect("parse");
assert_eq!(cfg.serbero.private_key, "aa11");
assert_eq!(cfg.serbero.db_path, "serbero.db");
assert_eq!(cfg.serbero.log_level, "info");
}
const ZERO_INTERVAL_CONFIG: &str = r#"
[serbero]
private_key = "aa11"
[mostro]
pubkey = "bb22"
[escalation]
enabled = true
dispatch_interval_seconds = 0
"#;
#[test]
fn zero_dispatch_interval_is_rejected_loudly() {
let _lock = EnvLock::new();
let f = write_tmp(ZERO_INTERVAL_CONFIG);
let err = load_config(f.path()).unwrap_err();
match err {
Error::Config(msg) => {
assert!(
msg.contains("dispatch_interval_seconds"),
"error message should name the field: {msg}"
);
}
other => panic!("expected Error::Config for zero-interval, got {other:?}"),
}
}
}