use std::path::{Path, PathBuf};
use figment::providers::{Env, Format, Serialized, Toml};
use figment::Figment;
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const SETTINGS_SCHEMA_VERSION: u32 = 1;
fn default_schema_version() -> u32 {
SETTINGS_SCHEMA_VERSION
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings {
#[serde(default = "default_schema_version")]
pub schema_version: u32,
pub engine: EngineSettings,
pub journal: JournalSettings,
pub ui: UiSettings,
}
impl Default for Settings {
fn default() -> Self {
Self {
schema_version: SETTINGS_SCHEMA_VERSION,
engine: EngineSettings::default(),
journal: JournalSettings::default(),
ui: UiSettings::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct EngineSettings {
pub retry_budget_secs: u64,
pub retry_initial_backoff_ms: u64,
pub openvpn_verbosity: String,
pub connect_timeout_secs: u64,
}
impl Default for EngineSettings {
fn default() -> Self {
Self {
retry_budget_secs: 300,
retry_initial_backoff_ms: 2_000,
openvpn_verbosity: "3".to_string(),
connect_timeout_secs: 30,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct JournalSettings {
pub disk: bool,
pub retention_days: u32,
pub retention_count: u32,
}
impl Default for JournalSettings {
fn default() -> Self {
Self {
disk: true,
retention_days: 30,
retention_count: 30,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct UiSettings {
pub start_mode: StartMode,
}
impl Default for UiSettings {
fn default() -> Self {
Self {
start_mode: StartMode::Tui,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StartMode {
Tui,
Cli,
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum SettingsError {
#[error("figment error: {0}")]
Figment(Box<figment::Error>),
#[error("I/O error resolving config path: {0}")]
Io(#[from] std::io::Error),
#[error("no usable config directory (XDG resolution failed)")]
NoConfigDir,
#[error(
"settings schema version {found} is not supported by this build (max supported: {supported_max}). Upgrade vortix or migrate the file."
)]
UnsupportedSchema { found: u32, supported_max: u32 },
}
pub fn migrate_settings(mut s: Settings) -> Result<Settings, SettingsError> {
match s.schema_version {
0 | 1 => {
s.schema_version = SETTINGS_SCHEMA_VERSION;
Ok(s)
}
found => Err(SettingsError::UnsupportedSchema {
found,
supported_max: SETTINGS_SCHEMA_VERSION,
}),
}
}
impl From<figment::Error> for SettingsError {
fn from(e: figment::Error) -> Self {
Self::Figment(Box::new(e))
}
}
impl Settings {
pub fn load() -> Result<Self, SettingsError> {
let user_path = user_config_path()?;
Self::load_from(None, Some(&user_path))
}
pub fn load_from(system: Option<&Path>, user: Option<&Path>) -> Result<Self, SettingsError> {
let mut fig = Figment::new().merge(Serialized::defaults(Self::default()));
if let Some(p) = system {
if p.exists() {
fig = fig.merge(Toml::file(p));
}
}
if let Some(p) = user {
if p.exists() {
fig = fig.merge(Toml::file(p));
}
}
fig = fig.merge(Env::prefixed("VORTIX_").split("__"));
let s: Self = fig.extract()?;
migrate_settings(s)
}
}
pub fn user_config_path() -> Result<PathBuf, SettingsError> {
use directories::ProjectDirs;
#[cfg(unix)]
if let Ok(sudo_user) = std::env::var("SUDO_USER") {
if !sudo_user.is_empty() {
if let Some(home) = sudo_home(&sudo_user) {
return Ok(home.join(".config").join("vortix").join("settings.toml"));
}
}
}
let pd = ProjectDirs::from("", "", "vortix").ok_or(SettingsError::NoConfigDir)?;
Ok(pd.config_dir().join("settings.toml"))
}
#[cfg(unix)]
fn sudo_home(user: &str) -> Option<PathBuf> {
if std::env::var("USER").as_deref() == Ok(user) {
return std::env::var("HOME").ok().map(PathBuf::from);
}
None
}
#[cfg(not(unix))]
fn sudo_home(_user: &str) -> Option<PathBuf> {
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn defaults_load_without_files() {
let s = Settings::load_from(None, None).unwrap();
assert_eq!(s.engine.retry_budget_secs, 300);
assert_eq!(s.engine.retry_initial_backoff_ms, 2_000);
assert!(s.journal.disk);
assert_eq!(s.journal.retention_days, 30);
}
#[test]
fn user_file_overrides_defaults() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("settings.toml");
fs::write(
&path,
"
[engine]
retry_budget_secs = 60
[journal]
disk = false
",
)
.unwrap();
let s = Settings::load_from(None, Some(&path)).unwrap();
assert_eq!(s.engine.retry_budget_secs, 60);
assert!(!s.journal.disk);
assert_eq!(s.journal.retention_days, 30);
}
#[test]
fn user_file_overrides_system_file() {
let tmp = tempfile::tempdir().unwrap();
let sys = tmp.path().join("system.toml");
let user = tmp.path().join("user.toml");
fs::write(&sys, "[engine]\nretry_budget_secs = 60\n").unwrap();
fs::write(&user, "[engine]\nretry_budget_secs = 120\n").unwrap();
let s = Settings::load_from(Some(&sys), Some(&user)).unwrap();
assert_eq!(s.engine.retry_budget_secs, 120);
}
#[test]
fn invalid_toml_surfaces_error() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("bad.toml");
fs::write(&path, "[engine]\nretry_budget_secs = \"not a number\"\n").unwrap();
let err = Settings::load_from(None, Some(&path)).unwrap_err();
assert!(matches!(err, SettingsError::Figment(_)));
}
#[test]
fn schema_version_defaults_to_one() {
let s = Settings::load_from(None, None).unwrap();
assert_eq!(s.schema_version, 1);
}
#[test]
fn missing_schema_version_in_file_defaults_to_one() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("legacy.toml");
fs::write(&path, "[engine]\nretry_budget_secs = 60\n").unwrap();
let s = Settings::load_from(None, Some(&path)).unwrap();
assert_eq!(s.schema_version, 1);
assert_eq!(s.engine.retry_budget_secs, 60);
}
#[test]
fn explicit_schema_version_one_loads_cleanly() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("v1.toml");
fs::write(
&path,
"schema_version = 1\n[engine]\nretry_budget_secs = 90\n",
)
.unwrap();
let s = Settings::load_from(None, Some(&path)).unwrap();
assert_eq!(s.schema_version, 1);
assert_eq!(s.engine.retry_budget_secs, 90);
}
#[test]
fn unsupported_schema_version_returns_typed_error() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("v999.toml");
fs::write(&path, "schema_version = 999\n").unwrap();
let err = Settings::load_from(None, Some(&path)).unwrap_err();
match err {
SettingsError::UnsupportedSchema {
found,
supported_max,
} => {
assert_eq!(found, 999);
assert_eq!(supported_max, SETTINGS_SCHEMA_VERSION);
}
other => panic!("expected UnsupportedSchema, got {other:?}"),
}
}
#[test]
fn migrate_settings_normalises_zero_to_current() {
let engine = EngineSettings {
retry_budget_secs: 42,
..EngineSettings::default()
};
let s = Settings {
schema_version: 0,
engine,
journal: JournalSettings::default(),
ui: UiSettings::default(),
};
let migrated = migrate_settings(s).unwrap();
assert_eq!(migrated.schema_version, SETTINGS_SCHEMA_VERSION);
assert_eq!(migrated.engine.retry_budget_secs, 42);
}
}