use serde::{Deserialize, Serialize};
use std::path::PathBuf;
const DEFAULT_PREFIXES: &[&str] = &["/tmp", "/var/tmp", "/home"];
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[allow(clippy::exhaustive_structs)]
pub struct RuntimoConfig {
#[serde(default)]
pub allowed_paths: Vec<String>,
}
impl RuntimoConfig {
pub fn config_path() -> PathBuf {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| {
std::env::var("HOME")
.ok()
.map(|h| PathBuf::from(h).join(".config"))
});
if let Some(dir) = base {
dir.join("runtimo/config.toml")
} else {
eprintln!(
"[runtimo] Warning: XDG_CONFIG_HOME and HOME unset — using /tmp/runtimo \
(config will not survive reboot)"
);
PathBuf::from("/tmp/runtimo/config.toml")
}
}
#[must_use]
pub fn load() -> Self {
match Self::load_result() {
Ok(config) => config,
Err(e) => {
eprintln!("[runtimo] Config load failed (using defaults): {}", e);
Self::default()
}
}
}
pub fn load_result() -> Result<Self, String> {
let path = Self::config_path();
if path.exists() {
let content = std::fs::read_to_string(&path)
.map_err(|e| format!("Cannot read config file '{}': {}", path.display(), e))?;
toml::from_str(&content)
.map_err(|e| format!("Cannot parse config file '{}': {}", path.display(), e))
} else {
Ok(Self::default())
}
}
pub fn save(&self) -> Result<(), String> {
let path = Self::config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let content = toml::to_string_pretty(self).map_err(|e| e.to_string())?;
std::fs::write(&path, content).map_err(|e| e.to_string())?;
Ok(())
}
#[must_use]
pub fn get_allowed_prefixes() -> Vec<String> {
let mut prefixes: Vec<String> = DEFAULT_PREFIXES.iter().map(|s| s.to_string()).collect();
if let Ok(env_paths) = std::env::var("RUNTIMO_ALLOWED_PATHS") {
for p in env_paths.split(':').filter(|s| !s.is_empty()) {
let trimmed = p.trim().to_string();
if !prefixes.contains(&trimmed) {
prefixes.push(trimmed);
}
}
}
let config = Self::load();
for p in &config.allowed_paths {
let trimmed = p.trim().to_string();
if !prefixes.contains(&trimmed) {
prefixes.push(trimmed);
}
}
prefixes
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static CONFIG_TEST_MUTEX: Mutex<()> = Mutex::new(());
#[test]
fn config_path_is_absolute() {
let path = RuntimoConfig::config_path();
assert!(path.is_absolute());
}
#[test]
fn load_returns_defaults_when_no_file() {
let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
let tmp = std::env::temp_dir().join("runtimo_test_config_defaults");
let _ = std::fs::remove_dir_all(&tmp);
std::env::set_var("XDG_CONFIG_HOME", &tmp);
let config = RuntimoConfig::load();
assert!(config.allowed_paths.is_empty());
let _ = std::fs::remove_dir_all(&tmp);
std::env::remove_var("XDG_CONFIG_HOME");
}
#[test]
fn get_allowed_prefixes_includes_defaults() {
let prefixes = RuntimoConfig::get_allowed_prefixes();
assert!(prefixes.iter().any(|p| p == "/tmp"));
assert!(prefixes.iter().any(|p| p == "/var/tmp"));
assert!(prefixes.iter().any(|p| p == "/home"));
}
#[test]
fn save_and_load_roundtrip() {
let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
let tmp = std::env::temp_dir().join("runtimo_test_config");
std::env::set_var("XDG_CONFIG_HOME", &tmp);
let mut config = RuntimoConfig::default();
config.allowed_paths.push("/srv".to_string());
config.allowed_paths.push("/opt".to_string());
config.save().expect("save failed");
let loaded = RuntimoConfig::load();
assert_eq!(loaded.allowed_paths, vec!["/srv", "/opt"]);
let prefixes = RuntimoConfig::get_allowed_prefixes();
assert!(prefixes.contains(&"/srv".to_string()));
assert!(prefixes.contains(&"/opt".to_string()));
let _ = std::fs::remove_dir_all(&tmp);
std::env::remove_var("XDG_CONFIG_HOME");
}
#[test]
fn test_toml_parse_failure_returns_defaults() {
let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
let tmp = std::env::temp_dir().join("runtimo_test_config_corrupt");
let config_dir = tmp.join("runtimo");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&config_dir).unwrap();
let config_path = config_dir.join("config.toml");
std::fs::write(&config_path, "this is {{{ not valid toml at all!!!").unwrap();
std::env::set_var("XDG_CONFIG_HOME", &tmp);
let config = RuntimoConfig::load();
assert!(
config.allowed_paths.is_empty(),
"Corrupt TOML should return defaults"
);
let _ = std::fs::remove_dir_all(&tmp);
std::env::remove_var("XDG_CONFIG_HOME");
}
#[test]
fn test_empty_config_file_returns_defaults() {
let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
let tmp = std::env::temp_dir().join("runtimo_test_config_empty");
let config_dir = tmp.join("runtimo");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&config_dir).unwrap();
let config_path = config_dir.join("config.toml");
std::fs::write(&config_path, "").unwrap();
std::env::set_var("XDG_CONFIG_HOME", &tmp);
let config = RuntimoConfig::load();
assert!(
config.allowed_paths.is_empty(),
"Empty config should return defaults"
);
let _ = std::fs::remove_dir_all(&tmp);
std::env::remove_var("XDG_CONFIG_HOME");
}
#[test]
fn test_toml_missing_section_returns_defaults() {
let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
let tmp = std::env::temp_dir().join("runtimo_test_config_missing");
let config_dir = tmp.join("runtimo");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&config_dir).unwrap();
let config_path = config_dir.join("config.toml");
std::fs::write(&config_path, "[other_section]\nfoo = \"bar\"\n").unwrap();
std::env::set_var("XDG_CONFIG_HOME", &tmp);
let config = RuntimoConfig::load();
assert!(
config.allowed_paths.is_empty(),
"Missing section should return defaults"
);
let _ = std::fs::remove_dir_all(&tmp);
std::env::remove_var("XDG_CONFIG_HOME");
}
}