use std::marker::PhantomData;
use std::path::PathBuf;
use crate::config::SettingsSchema;
use crate::storage::{JsonStorage, StorageBackend};
#[cfg(feature = "backup")]
use crate::backup::ExternalConfig;
use crate::credentials::CredentialBackend;
use std::sync::Arc;
#[derive(Clone)]
pub enum CredentialConfig {
Disabled,
Default,
#[cfg(all(feature = "keychain", feature = "encrypted-file"))]
WithFallback {
fallback_path: Option<std::path::PathBuf>,
password: crate::credentials::SecretPasswordSource,
},
Custom(Arc<dyn CredentialBackend>),
}
impl std::fmt::Debug for CredentialConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Disabled => write!(f, "Disabled"),
Self::Default => write!(f, "Default"),
#[cfg(all(feature = "keychain", feature = "encrypted-file"))]
Self::WithFallback {
fallback_path,
password,
} => f
.debug_struct("WithFallback")
.field("fallback_path", fallback_path)
.field("password", password)
.finish(),
Self::Custom(_) => f
.debug_tuple("Custom")
.field(&"<dyn CredentialBackend>")
.finish(),
}
}
}
pub trait EnvSource: Send + Sync {
fn var(&self, key: &str) -> std::result::Result<String, std::env::VarError>;
}
#[derive(Clone, Default)]
pub struct DefaultEnvSource;
impl EnvSource for DefaultEnvSource {
fn var(&self, key: &str) -> std::result::Result<String, std::env::VarError> {
std::env::var(key)
}
}
#[cfg(feature = "hot-reload")]
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub enum HotReloadBackend {
#[default]
Auto,
Poll,
}
#[cfg(feature = "hot-reload")]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HotReloadConfig {
pub debounce_ms: u64,
pub poll_interval_ms: u64,
pub backend: HotReloadBackend,
}
#[cfg(feature = "hot-reload")]
impl Default for HotReloadConfig {
fn default() -> Self {
Self {
debounce_ms: 200,
poll_interval_ms: 1000,
backend: HotReloadBackend::Auto,
}
}
}
pub struct SettingsConfig<S: StorageBackend = JsonStorage, Schema: SettingsSchema = ()> {
pub config_dir: PathBuf,
pub settings_file: String,
pub app_name: String,
pub app_version: String,
pub(crate) storage: S,
pub credential_config: CredentialConfig,
pub env_prefix: Option<String>,
pub env_overrides_secrets: bool,
#[cfg(feature = "backup")]
pub external_configs: Vec<ExternalConfig>,
pub migrator:
Option<std::sync::Arc<dyn Fn(serde_json::Value) -> serde_json::Value + Send + Sync>>,
#[cfg(feature = "profiles")]
pub profiles_enabled: bool,
#[cfg(feature = "profiles")]
pub profile_migrator: crate::profiles::ProfileMigrator,
#[doc(hidden)]
pub _schema: PhantomData<Schema>,
pub env_source: std::sync::Arc<dyn EnvSource>,
#[cfg(feature = "hot-reload")]
pub hot_reload: Option<HotReloadConfig>,
}
impl Default for SettingsConfig {
fn default() -> Self {
let storage = JsonStorage::new();
let settings_file = format!("settings.{}", storage.extension());
Self {
config_dir: PathBuf::from("."),
settings_file,
app_name: "app".into(),
app_version: "0.1.0".into(),
storage,
credential_config: CredentialConfig::Disabled,
env_prefix: None,
env_overrides_secrets: false,
#[cfg(feature = "backup")]
external_configs: Vec::new(),
migrator: None,
#[cfg(feature = "profiles")]
profiles_enabled: false,
#[cfg(feature = "profiles")]
profile_migrator: crate::profiles::ProfileMigrator::default(),
_schema: PhantomData,
env_source: std::sync::Arc::new(DefaultEnvSource),
#[cfg(feature = "hot-reload")]
hot_reload: None,
}
}
}
impl<S: StorageBackend, Schema: SettingsSchema> SettingsConfig<S, Schema> {
pub fn settings_path(&self) -> PathBuf {
self.config_dir.join(&self.settings_file)
}
}
impl SettingsConfig {
pub fn builder(
app_name: impl Into<String>,
app_version: impl Into<String>,
) -> SettingsConfigBuilder {
SettingsConfigBuilder::new(app_name, app_version)
}
}
#[derive(Clone)]
pub struct SettingsConfigBuilder<S: StorageBackend = JsonStorage, Schema: SettingsSchema = ()> {
config_dir: Option<PathBuf>,
settings_file: Option<String>,
app_name: String,
app_version: String,
options: BuilderOptions,
env_prefix: Option<String>,
#[cfg(feature = "backup")]
external_configs: Vec<ExternalConfig>,
migrator: Option<std::sync::Arc<dyn Fn(serde_json::Value) -> serde_json::Value + Send + Sync>>,
#[cfg(feature = "profiles")]
profile_migrator: Option<crate::profiles::ProfileMigrator>,
env_source: Option<std::sync::Arc<dyn EnvSource>>,
_schema: PhantomData<Schema>,
_storage: PhantomData<S>,
}
#[derive(Clone, Debug, Default)]
struct BuilderConfigFlags {
pretty_json: bool,
#[cfg(feature = "profiles")]
profiles_enabled: bool,
#[cfg(feature = "hot-reload")]
hot_reload: Option<HotReloadConfig>,
}
#[derive(Clone, Debug)]
struct BuilderSecurityFlags {
credential_config: CredentialConfig,
env_overrides_secrets: bool,
}
impl Default for BuilderSecurityFlags {
fn default() -> Self {
Self {
credential_config: CredentialConfig::Disabled,
env_overrides_secrets: false,
}
}
}
#[derive(Clone, Debug, Default)]
struct BuilderOptions {
config: BuilderConfigFlags,
security: BuilderSecurityFlags,
}
impl<S: StorageBackend, Schema: SettingsSchema> std::fmt::Debug
for SettingsConfigBuilder<S, Schema>
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut debug = f.debug_struct("SettingsConfigBuilder");
debug
.field("config_dir", &self.config_dir)
.field("settings_file", &self.settings_file)
.field("app_name", &self.app_name)
.field("app_version", &self.app_version)
.field("pretty_json", &self.options.config.pretty_json)
.field(
"credential_config",
&self.options.security.credential_config,
)
.field("env_prefix", &self.env_prefix)
.field(
"env_overrides_secrets",
&self.options.security.env_overrides_secrets,
);
#[cfg(feature = "backup")]
debug.field("external_configs", &self.external_configs);
#[cfg(feature = "profiles")]
debug.field("profiles_enabled", &self.options.config.profiles_enabled);
#[cfg(feature = "profiles")]
debug.field("profile_migrator", &self.profile_migrator);
debug.field("migrator", &self.migrator.as_ref().map(|_| "Some(Fn)"));
debug.finish_non_exhaustive()
}
}
impl SettingsConfigBuilder {
pub fn new(app_name: impl Into<String>, app_version: impl Into<String>) -> Self {
Self {
config_dir: None,
settings_file: None,
app_name: app_name.into(),
app_version: app_version.into(),
options: BuilderOptions::default(),
env_prefix: None,
#[cfg(feature = "backup")]
external_configs: Vec::new(),
migrator: None,
#[cfg(feature = "profiles")]
profile_migrator: None,
env_source: None,
_schema: PhantomData,
_storage: PhantomData,
}
}
}
impl<S: StorageBackend, Schema: SettingsSchema> SettingsConfigBuilder<S, Schema> {
#[must_use]
pub fn with_pretty_json(mut self, pretty: bool) -> Self {
self.options.config.pretty_json = pretty;
self
}
#[must_use]
pub fn with_config_dir(mut self, path: impl Into<PathBuf>) -> Self {
let path: PathBuf = path.into();
let expanded = if path.starts_with("~") {
if let Some(home) = dirs::home_dir() {
home.join(path.strip_prefix("~").unwrap_or(&path))
} else {
path
}
} else {
path
};
self.config_dir = Some(expanded);
self
}
#[must_use]
pub fn settings_file(mut self, filename: impl Into<String>) -> Self {
self.settings_file = Some(filename.into());
self
}
#[must_use]
pub fn with_credentials(mut self) -> Self {
self.options.security.credential_config = CredentialConfig::Default;
self
}
#[must_use]
pub fn with_credential_config(mut self, config: CredentialConfig) -> Self {
self.options.security.credential_config = config;
self
}
#[cfg(all(feature = "keychain", feature = "encrypted-file"))]
#[must_use]
pub fn with_encrypted_fallback(
mut self,
path: impl Into<std::path::PathBuf>,
password_source: crate::credentials::SecretPasswordSource,
) -> Self {
self.options.security.credential_config = CredentialConfig::WithFallback {
fallback_path: Some(path.into()),
password: password_source,
};
self
}
#[cfg(all(feature = "keychain", feature = "encrypted-file"))]
#[must_use]
pub fn with_env_credentials(mut self) -> Self {
let base_name = self.app_name.to_uppercase().replace(['-', '.'], "_");
let secret_var = format!("{base_name}_SECRET");
let path_var = format!("{base_name}_SECRET_PATH");
let secret_file_var = format!("{base_name}_SECRET_FILE");
let secret_env_is_set = std::env::var(&secret_var).is_ok_and(|v| !v.is_empty());
if let Ok(secret_file) = std::env::var(&secret_file_var)
&& !secret_file.is_empty()
{
self.options.security.credential_config = CredentialConfig::WithFallback {
fallback_path: None,
password: crate::credentials::SecretPasswordSource::File(std::path::PathBuf::from(
secret_file,
)),
};
return self;
}
if let Ok(path) = std::env::var(&path_var)
&& !path.is_empty()
{
let path_buf = std::path::PathBuf::from(path);
if secret_env_is_set {
self.options.security.credential_config = CredentialConfig::WithFallback {
fallback_path: Some(path_buf),
password: crate::credentials::SecretPasswordSource::Environment(secret_var),
};
return self;
}
if path_buf.is_file() {
self.options.security.credential_config = CredentialConfig::WithFallback {
fallback_path: None,
password: crate::credentials::SecretPasswordSource::File(path_buf),
};
return self;
}
self.options.security.credential_config = CredentialConfig::WithFallback {
fallback_path: Some(path_buf),
password: crate::credentials::SecretPasswordSource::Environment(secret_var),
};
return self;
}
self.with_custom_env_credentials(secret_var)
}
#[cfg(all(feature = "keychain", feature = "encrypted-file"))]
#[must_use]
pub fn with_custom_env_credentials(mut self, var_name: impl Into<String>) -> Self {
self.options.security.credential_config = CredentialConfig::WithFallback {
fallback_path: None,
password: crate::credentials::SecretPasswordSource::Environment(var_name.into()),
};
self
}
#[cfg(all(feature = "keychain", feature = "encrypted-file"))]
#[must_use]
pub fn with_file_credentials(mut self, path: impl Into<std::path::PathBuf>) -> Self {
self.options.security.credential_config = CredentialConfig::WithFallback {
fallback_path: None,
password: crate::credentials::SecretPasswordSource::File(path.into()),
};
self
}
#[cfg(all(feature = "keychain", feature = "encrypted-file"))]
#[must_use]
pub fn with_password_credentials(mut self, password: impl Into<String>) -> Self {
self.options.security.credential_config = CredentialConfig::WithFallback {
fallback_path: None,
password: crate::credentials::SecretPasswordSource::Provided(password.into()),
};
self
}
#[cfg(feature = "backup")]
#[must_use]
pub fn with_external_config(mut self, config: ExternalConfig) -> Self {
self.external_configs.push(config);
self
}
#[must_use]
pub fn with_env_prefix(mut self, prefix: impl Into<String>) -> Self {
self.env_prefix = Some(prefix.into());
self
}
#[must_use]
pub fn env_overrides_secrets(mut self, allow: bool) -> Self {
self.options.security.env_overrides_secrets = allow;
self
}
#[must_use]
pub fn with_env_source(mut self, source: std::sync::Arc<dyn EnvSource>) -> Self {
self.env_source = Some(source);
self
}
#[must_use]
pub fn with_migrator<F>(mut self, migrator: F) -> Self
where
F: Fn(serde_json::Value) -> serde_json::Value + Send + Sync + 'static,
{
self.migrator = Some(std::sync::Arc::new(migrator));
self
}
#[cfg(feature = "hot-reload")]
#[must_use]
pub fn with_hot_reload(mut self) -> Self {
self.options.config.hot_reload = Some(HotReloadConfig::default());
self
}
#[cfg(feature = "hot-reload")]
#[must_use]
pub fn with_hot_reload_config(mut self, config: HotReloadConfig) -> Self {
self.options.config.hot_reload = Some(config);
self
}
#[cfg(feature = "profiles")]
#[must_use]
pub fn with_profiles(mut self) -> Self {
self.options.config.profiles_enabled = true;
self
}
#[must_use]
pub fn with_schema<NewSchema: SettingsSchema>(self) -> SettingsConfigBuilder<S, NewSchema> {
SettingsConfigBuilder {
config_dir: self.config_dir,
settings_file: self.settings_file,
app_name: self.app_name,
app_version: self.app_version,
options: self.options,
env_prefix: self.env_prefix,
#[cfg(feature = "backup")]
external_configs: self.external_configs,
migrator: self.migrator,
#[cfg(feature = "profiles")]
profile_migrator: self.profile_migrator,
env_source: self.env_source,
_schema: PhantomData,
_storage: PhantomData,
}
}
#[must_use]
pub fn with_storage<NewS: StorageBackend + Default>(
self,
) -> SettingsConfigBuilder<NewS, Schema> {
SettingsConfigBuilder {
config_dir: self.config_dir,
settings_file: self.settings_file,
app_name: self.app_name,
app_version: self.app_version,
options: self.options,
env_prefix: self.env_prefix,
#[cfg(feature = "backup")]
external_configs: self.external_configs,
migrator: self.migrator,
#[cfg(feature = "profiles")]
profile_migrator: self.profile_migrator,
env_source: self.env_source,
_schema: PhantomData,
_storage: PhantomData,
}
}
#[must_use]
pub fn build(self) -> SettingsConfig<S, Schema>
where
S: Default,
{
let config_dir = self.config_dir.unwrap_or_else(|| {
dirs::config_dir().map_or_else(|| PathBuf::from("."), |d| d.join(&self.app_name))
});
let storage = S::default();
let settings_file = self
.settings_file
.unwrap_or_else(|| format!("settings.{}", storage.extension()));
#[cfg(all(feature = "keychain", feature = "encrypted-file"))]
let mut credential_config = self.options.security.credential_config;
#[cfg(not(all(feature = "keychain", feature = "encrypted-file")))]
let credential_config = self.options.security.credential_config;
#[cfg(all(feature = "keychain", feature = "encrypted-file"))]
{
if let CredentialConfig::WithFallback {
ref mut fallback_path,
..
} = credential_config
&& fallback_path.is_none()
{
*fallback_path = Some(config_dir.join("secrets.enc"));
}
}
SettingsConfig {
config_dir,
settings_file,
app_name: self.app_name,
app_version: self.app_version,
storage,
credential_config,
env_prefix: self.env_prefix,
env_overrides_secrets: self.options.security.env_overrides_secrets,
#[cfg(feature = "backup")]
external_configs: self.external_configs,
migrator: self.migrator,
#[cfg(feature = "profiles")]
profiles_enabled: self.options.config.profiles_enabled,
#[cfg(feature = "profiles")]
profile_migrator: self.profile_migrator.unwrap_or_default(),
_schema: PhantomData,
env_source: self
.env_source
.unwrap_or_else(|| std::sync::Arc::new(DefaultEnvSource)),
#[cfg(feature = "hot-reload")]
hot_reload: self.options.config.hot_reload,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_builder_basic() {
let config = SettingsConfig::builder("test-app", "1.0.0").build();
assert_eq!(config.app_name, "test-app");
assert_eq!(config.app_version, "1.0.0");
assert_eq!(config.settings_file, "settings.json");
}
#[test]
fn test_builder_with_options() {
let config = SettingsConfig::builder("my-app", "2.0.0")
.with_config_dir("/tmp/my-app")
.settings_file("config.json")
.build();
assert_eq!(config.config_dir, PathBuf::from("/tmp/my-app"));
assert_eq!(config.settings_file, "config.json");
}
#[test]
#[cfg(all(feature = "keychain", feature = "encrypted-file"))]
fn test_builder_credentials_auto_path() {
let config = SettingsConfig::builder("my-app", "1.0.0")
.with_config_dir("/tmp/my-app")
.with_custom_env_credentials("MYAPP_KEY")
.build();
if let CredentialConfig::WithFallback {
fallback_path,
password,
} = config.credential_config
{
assert_eq!(
fallback_path,
Some(PathBuf::from("/tmp/my-app/secrets.enc"))
);
assert_eq!(
password,
crate::credentials::SecretPasswordSource::Environment("MYAPP_KEY".into())
);
} else {
panic!("Expected CredentialConfig::WithFallback");
}
}
#[test]
#[cfg(all(feature = "keychain", feature = "encrypted-file"))]
fn test_builder_credentials_default_name() {
let config = SettingsConfig::builder("my-app", "1.0.0")
.with_env_credentials()
.build();
if let CredentialConfig::WithFallback { password, .. } = config.credential_config {
assert_eq!(
password,
crate::credentials::SecretPasswordSource::Environment("MY_APP_SECRET".to_string())
);
} else {
panic!("Expected CredentialConfig::WithFallback");
}
}
#[test]
#[cfg(all(feature = "keychain", feature = "encrypted-file"))]
fn test_builder_credentials_automated_path() {
unsafe {
std::env::set_var("MY_APP_SECRET_PATH", "/tmp/mystic_path.enc");
}
let config = SettingsConfig::builder("my-app", "1.0.0")
.with_env_credentials()
.build();
if let CredentialConfig::WithFallback {
fallback_path,
password,
} = config.credential_config
{
assert_eq!(
fallback_path,
Some(std::path::PathBuf::from("/tmp/mystic_path.enc"))
);
assert_eq!(
password,
crate::credentials::SecretPasswordSource::Environment("MY_APP_SECRET".to_string())
);
} else {
panic!("Expected CredentialConfig::WithFallback");
}
unsafe {
std::env::remove_var("MY_APP_SECRET_PATH");
}
}
}