use directories::ProjectDirs;
use serde::de::{self, Deserializer};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use twelf::reexports::toml;
use twelf::{Layer, config};
#[cfg(feature = "onepassword")]
use terraphim_onepassword_cli::{OnePasswordLoader, SecretLoader};
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("config error: {0}")]
ConfigError(#[from] twelf::Error),
#[error("io error: {0}")]
IoError(#[from] std::io::Error),
#[error("env error: {0}")]
EnvError(#[from] std::env::VarError),
#[cfg(feature = "onepassword")]
#[error("1Password error: {0}")]
OnePasswordError(#[from] terraphim_onepassword_cli::OnePasswordError),
}
pub type DeviceSettingsResult<T> = std::result::Result<T, Error>;
pub const DEFAULT_CONFIG_PATH: &str = ".config";
pub const DEFAULT_SETTINGS: &str = include_str!("../default/settings_local_dev.toml");
fn deserialize_bool_from_string<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum BoolOrString {
Bool(bool),
String(String),
}
match BoolOrString::deserialize(deserializer)? {
BoolOrString::Bool(b) => Ok(b),
BoolOrString::String(s) => match s.to_lowercase().as_str() {
"true" => Ok(true),
"false" => Ok(false),
_ => Err(de::Error::custom(format!("invalid boolean value: {}", s))),
},
}
}
#[config]
#[derive(Debug, Serialize, Clone)]
pub struct DeviceSettings {
pub server_hostname: String,
pub api_endpoint: String,
#[serde(deserialize_with = "deserialize_bool_from_string")]
pub initialized: bool,
pub default_data_path: String,
pub profiles: HashMap<String, HashMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role_config: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_role: Option<String>,
}
impl Default for DeviceSettings {
fn default() -> Self {
Self::new()
}
}
impl DeviceSettings {
pub fn new() -> Self {
Self::load_from_env_and_file(None).unwrap_or_else(|e| {
log::warn!(
"Failed to load device settings from file: {:?}, using defaults",
e
);
Self::default_embedded()
})
}
#[cfg(feature = "onepassword")]
pub async fn load_with_onepassword(config_path: Option<PathBuf>) -> DeviceSettingsResult<Self> {
log::info!("Loading device settings with 1Password integration...");
let config_path = config_path.unwrap_or_else(Self::default_config_path);
log::debug!("Settings path: {:?}", config_path);
let config_file = init_config_file(&config_path)?;
log::debug!("Loading config_file: {:?}", config_file);
let raw_config = std::fs::read_to_string(&config_file)?;
let loader = OnePasswordLoader::new();
let processed_config = if loader.is_available().await {
log::info!("1Password CLI available, processing secrets...");
loader.process_config(&raw_config).await?
} else {
log::warn!("1Password CLI not available, using raw configuration");
raw_config
};
let settings: DeviceSettings = toml::from_str(&processed_config).map_err(|e| {
Error::IoError(std::io::Error::other(format!("TOML parsing error: {}", e)))
})?;
log::info!("Successfully loaded settings with 1Password integration");
Ok(settings)
}
#[cfg(feature = "onepassword")]
pub async fn process_config_with_secrets(config: &str) -> DeviceSettingsResult<String> {
let loader = OnePasswordLoader::new();
if loader.is_available().await {
Ok(loader.process_config(config).await?)
} else {
log::warn!("1Password CLI not available, returning raw configuration");
Ok(config.to_string())
}
}
pub fn default_embedded() -> Self {
use std::collections::HashMap;
let mut profiles = HashMap::new();
let data_dir = if let Some(proj_dirs) = ProjectDirs::from("com", "aks", "terraphim") {
proj_dirs.data_dir().to_string_lossy().to_string()
} else if let Ok(home) = std::env::var("HOME") {
format!("{}/.terraphim", home)
} else {
"/tmp/terraphim_embedded".to_string()
};
let mut sqlite_profile = HashMap::new();
sqlite_profile.insert("type".to_string(), "sqlite".to_string());
sqlite_profile.insert("datadir".to_string(), format!("{}/sqlite", data_dir));
sqlite_profile.insert(
"connection_string".to_string(),
format!("{}/sqlite/terraphim.db", data_dir),
);
sqlite_profile.insert("table".to_string(), "terraphim_kv".to_string());
profiles.insert("sqlite".to_string(), sqlite_profile);
Self {
server_hostname: "127.0.0.1:8000".to_string(),
api_endpoint: "http://localhost:8000/api".to_string(),
initialized: true,
default_data_path: data_dir,
profiles,
role_config: None,
default_role: None,
}
}
pub fn default_config_path() -> PathBuf {
if let Some(proj_dirs) = ProjectDirs::from("com", "aks", "terraphim") {
let config_dir = proj_dirs.config_dir();
config_dir.to_path_buf()
} else {
PathBuf::from(DEFAULT_CONFIG_PATH)
}
}
pub fn load_from_env_and_file(config_path: Option<PathBuf>) -> DeviceSettingsResult<Self> {
log::info!("Loading device settings...");
let config_path = match config_path {
Some(path) => path,
None => DeviceSettings::default_config_path(),
};
log::debug!("Settings path: {:?}", config_path);
let config_file = init_config_file(&config_path)?;
log::debug!("Loading config_file: {:?}", config_file);
Ok(DeviceSettings::with_layers(&[
Layer::Toml(config_file),
Layer::Env(Some(String::from("TERRAPHIM_"))),
])?)
}
pub fn update_initialized_flag(
&mut self,
settings_path: Option<PathBuf>,
initialized: bool,
) -> Result<(), Error> {
let settings_path = settings_path.unwrap_or_else(Self::default_config_path);
let settings_path = settings_path.join("settings.toml");
self.initialized = initialized;
self.save(&settings_path)?;
Ok(())
}
pub fn save(&self, path: &PathBuf) -> Result<(), Error> {
log::info!("Saving device settings to: {:?}", path);
self.save_to_file(path)?;
Ok(())
}
fn save_to_file(&self, path: &PathBuf) -> Result<(), Error> {
let serialized_settings =
toml::to_string_pretty(self).map_err(|e| Error::IoError(std::io::Error::other(e)))?;
std::fs::write(path, serialized_settings).map_err(Error::IoError)?;
Ok(())
}
}
fn init_config_file(path: &PathBuf) -> Result<PathBuf, std::io::Error> {
if !path.exists() {
std::fs::create_dir_all(path)?;
}
let config_file = path.join("settings.toml");
if !config_file.exists() {
log::info!("Initializing default config file at: {:?}", path);
std::fs::write(&config_file, DEFAULT_SETTINGS)?;
} else {
log::debug!("Config file exists at: {:?}", config_file);
}
Ok(config_file)
}
#[cfg(test)]
mod tests {
use super::*;
use test_log::test;
use envtestkit::lock::lock_test;
use tempfile::TempDir;
#[test]
fn test_env_variable() {
let _lock = lock_test();
let temp_dir = TempDir::new().unwrap();
let config = DeviceSettings::load_from_env_and_file(Some(temp_dir.path().to_path_buf()));
log::debug!("Config: {:?}", config);
let config = config.unwrap();
assert!(config.profiles.contains_key("dashmap"));
assert!(config.profiles.contains_key("sqlite"));
let dashmap_profile = config.profiles.get("dashmap").unwrap();
assert!(dashmap_profile.contains_key("root"));
assert!(dashmap_profile.contains_key("type"));
assert_eq!(dashmap_profile.get("type").unwrap(), "dashmap");
}
#[test]
fn test_update_initialized_flag() {
let temp_dir = TempDir::new().unwrap();
let test_config_path = temp_dir.path().to_path_buf();
let mut config =
DeviceSettings::load_from_env_and_file(Some(test_config_path.clone())).unwrap();
config.initialized = false;
assert!(!config.initialized);
config
.update_initialized_flag(Some(test_config_path.clone()), true)
.unwrap();
let config_copy =
DeviceSettings::load_from_env_and_file(Some(test_config_path.clone())).unwrap();
assert!(config_copy.initialized);
}
#[test]
fn test_missing_role_config_defaults_to_none() {
let temp_dir = TempDir::new().unwrap();
let config =
DeviceSettings::load_from_env_and_file(Some(temp_dir.path().to_path_buf())).unwrap();
assert!(
config.role_config.is_none(),
"role_config should default to None when absent from TOML"
);
assert!(
config.default_role.is_none(),
"default_role should default to None when absent from TOML"
);
}
#[test]
fn test_role_config_loaded_from_toml() {
let temp_dir = TempDir::new().unwrap();
let settings_dir = temp_dir.path();
std::fs::create_dir_all(settings_dir).unwrap();
let settings_file = settings_dir.join("settings.toml");
std::fs::write(
&settings_file,
r#"
server_hostname = "127.0.0.1:8000"
api_endpoint = "http://localhost:8000/api"
initialized = "false"
default_data_path = "/tmp/test"
role_config = "~/.config/terraphim/my_roles.json"
default_role = "MyRole"
[profiles.dashmap]
type = "dashmap"
root = "/tmp/test_dashmap"
"#,
)
.unwrap();
let config =
DeviceSettings::load_from_env_and_file(Some(settings_dir.to_path_buf())).unwrap();
assert_eq!(
config.role_config.as_deref(),
Some("~/.config/terraphim/my_roles.json")
);
assert_eq!(config.default_role.as_deref(), Some("MyRole"));
}
}