use crate::model::RetryConfig;
use crate::SafeDisplay;
use figment::providers::{Env, Format, Serialized, Toml};
use figment::value::Value;
use figment::Figment;
use serde::{Deserialize, Serialize};
use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::time::Duration;
use url::Url;
const ENV_VAR_PREFIX: &str = "GOLEM__";
const ENV_VAR_NESTED_SEPARATOR: &str = "__";
pub trait ConfigLoaderConfig: Default + Serialize + Deserialize<'static> + SafeDisplay {}
impl<T: Default + Serialize + Deserialize<'static> + SafeDisplay> ConfigLoaderConfig for T {}
pub type ConfigExample<T> = (&'static str, T);
pub trait HasConfigExamples<T> {
fn examples() -> Vec<ConfigExample<T>>;
}
pub struct ConfigLoader<T: ConfigLoaderConfig> {
pub config_file_name: PathBuf,
pub make_examples: Option<fn() -> Vec<ConfigExample<T>>>,
config_type: std::marker::PhantomData<T>,
}
impl<T: ConfigLoaderConfig> ConfigLoader<T> {
pub fn new(config_file_name: &Path) -> ConfigLoader<T> {
let config_file_name = std::env::current_dir()
.expect("Failed to get current directory")
.join(config_file_name);
Self {
config_file_name,
make_examples: None,
config_type: std::marker::PhantomData,
}
}
pub fn new_with_examples(config_file_name: &Path) -> ConfigLoader<T>
where
T: HasConfigExamples<T>,
{
let config_file_name = std::env::current_dir()
.expect("Failed to get current directory")
.join(config_file_name);
Self {
config_file_name,
make_examples: Some(T::examples),
config_type: std::marker::PhantomData,
}
}
pub fn default_figment(&self) -> Figment {
Figment::new().merge(Serialized::defaults(T::default()))
}
pub fn example_figments(&self) -> Vec<(&'static str, Figment)> {
self.make_examples
.map_or(Vec::new(), |make| make())
.into_iter()
.map(|(name, example)| (name, Figment::new().merge(Serialized::defaults(example))))
.collect()
}
pub fn figment(&self) -> Figment {
Figment::new()
.merge(Serialized::defaults(T::default()))
.merge(Toml::file_exact(self.config_file_name.clone()))
.merge(env_config_provider())
}
pub fn load(&self) -> figment::Result<T> {
self.figment().extract::<T>()
}
fn default_dump_source(&self) -> dump::Source {
dump::Source::Default {
default: self.default_figment(),
examples: self.example_figments(),
}
}
fn loaded_dump_source(&self) -> dump::Source {
dump::Source::Loaded(self.figment())
}
pub fn load_or_dump_config(&self) -> Option<T> {
let args: Vec<String> = std::env::args().collect();
match args.get(1).map_or("", |a| a.as_str()) {
"--dump-config-default" => self.dump(self.default_dump_source(), &dump::print(false)),
"--dump-config-default-env-var" => {
self.dump(self.default_dump_source(), &dump::env_var())
}
"--dump-config-default-toml" => self.dump(self.default_dump_source(), &dump::toml()),
"--dump-config" => self.dump(self.loaded_dump_source(), &dump::print(true)),
"--dump-config-env-var" => self.dump(self.loaded_dump_source(), &dump::env_var()),
"--dump-config-toml" => self.dump(self.loaded_dump_source(), &dump::toml()),
other => {
if other.starts_with("--dump-config") {
panic!("Unknown dump config parameter: {other}");
} else {
match self.load() {
Ok(config) => Some(config),
Err(err) => {
eprintln!("Failed to load config: {err}");
None
}
}
}
}
}
}
fn dump<U>(&self, source: dump::Source, dump: &dump::Dump) -> Option<U> {
let extract =
|figment: &Figment| -> Value { figment.extract().expect("Failed to extract config") };
let dump_figment = |header: &str, is_example: bool, figment: &Figment| match dump {
dump::Dump::RootValue(dump) => dump.root_value(header, is_example, &extract(figment)),
dump::Dump::Visitor(dump) => {
dump.begin(header, is_example);
Self::visit_dump(
figment,
dump.as_ref(),
&mut Vec::new(),
"",
&extract(figment),
)
}
};
match source {
dump::Source::Default { default, examples } => {
dump_figment("Generated from default config", false, &default);
for (name, example) in examples {
dump_figment(
&format!("Generated from example config: {name}"),
true,
&example,
);
}
}
dump::Source::Loaded(loaded) => {
dump_figment("Generated from loaded config", false, &loaded);
}
}
None
}
fn visit_dump<'a>(
figment: &Figment,
dump: &dyn dump::VisitorDump,
path: &mut Vec<&'a str>,
name: &'a str,
value: &'a Value,
) {
match value {
Value::Dict(_, dict) => {
if !name.is_empty() {
path.push(name);
}
for (name, value) in dict.iter().filter(|(_, v)| v.as_dict().is_none()) {
Self::visit_dump(figment, dump, path, name, value)
}
for (name, value) in dict.iter().filter(|(_, v)| v.as_dict().is_some()) {
Self::visit_dump(figment, dump, path, name, value)
}
if !name.is_empty() {
path.pop();
}
}
value => dump.value(path, name, figment.get_metadata(value.tag()), value),
}
}
}
pub(crate) mod dump {
use crate::config::{ENV_VAR_NESTED_SEPARATOR, ENV_VAR_PREFIX};
use figment::value::Value;
use figment::{Figment, Metadata};
pub enum Source {
Default {
default: Figment,
examples: Vec<(&'static str, Figment)>,
},
Loaded(Figment),
}
pub fn print(show_source: bool) -> Dump {
Dump::Visitor(Box::new(Print { show_source }))
}
pub fn env_var() -> Dump {
Dump::Visitor(Box::new(EnvVar))
}
pub fn toml() -> Dump {
Dump::RootValue(Box::new(Toml))
}
pub enum Dump {
RootValue(Box<dyn RootValueDump>),
Visitor(Box<dyn VisitorDump>),
}
pub trait RootValueDump {
fn root_value(&self, header: &str, is_example: bool, value: &Value);
}
pub trait VisitorDump {
fn begin(&self, header: &str, is_example: bool);
fn value(&self, path: &[&str], name: &str, metadata: Option<&Metadata>, value: &Value);
}
struct Print {
pub show_source: bool,
}
impl VisitorDump for Print {
fn begin(&self, header: &str, is_example: bool) {
if is_example {
println!();
}
println!(":: {header}\n")
}
fn value(&self, path: &[&str], name: &str, metadata: Option<&Metadata>, value: &Value) {
if !path.is_empty() {
for elem in path {
print!("{elem}.");
}
}
if !name.is_empty() {
print!("{name}");
}
print!(
": {}",
serde_json::to_string(value).expect("Failed to pretty print value")
);
if self.show_source {
let name = metadata.map_or("unknown", |m| &m.name);
let source = metadata
.and_then(|m| m.source.as_ref())
.map_or("unknown".to_owned(), |s| s.to_string());
println!(" ({name} - {source})");
} else {
println!()
}
}
}
struct EnvVar;
impl VisitorDump for EnvVar {
fn begin(&self, header: &str, is_example: bool) {
if is_example {
println!();
}
println!("### {header}\n")
}
fn value(&self, path: &[&str], name: &str, _metadata: Option<&Metadata>, value: &Value) {
let is_empty_value = value.to_empty().is_some();
if is_empty_value {
print!("#");
}
print!("{ENV_VAR_PREFIX}");
if !path.is_empty() {
for elem in path {
print!("{}{}", elem.to_uppercase(), ENV_VAR_NESTED_SEPARATOR);
}
}
if !name.is_empty() {
print!("{}", name.to_uppercase());
}
if !is_empty_value {
println!(
"={}",
serde_json::to_string(value).expect("Failed to pretty print value")
);
} else {
println!("=");
}
}
}
pub struct Toml;
impl RootValueDump for Toml {
fn root_value(&self, header: &str, is_example: bool, value: &Value) {
if is_example {
println!();
}
println!("## {header}");
let mut config_as_toml_str =
toml::to_string(value).expect("Failed to serialize as TOML");
if is_example {
config_as_toml_str = config_as_toml_str
.lines()
.map(|l| format!("# {l}"))
.collect::<Vec<String>>()
.join("\n")
}
println!("{config_as_toml_str}");
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RedisConfig {
pub host: String,
pub port: u16,
pub database: usize,
pub tracing: bool,
pub pool_size: usize,
pub retries: RetryConfig,
pub key_prefix: String,
pub username: Option<String>,
pub password: Option<String>,
}
impl RedisConfig {
pub fn url(&self) -> Url {
Url::parse(&format!(
"redis://{}:{}/{}",
self.host, self.port, self.database
))
.expect("Failed to parse Redis URL")
}
}
impl SafeDisplay for RedisConfig {
fn to_safe_string(&self) -> String {
let mut result = String::new();
let _ = writeln!(&mut result, "host: {}", self.host);
let _ = writeln!(&mut result, "port: {}", self.port);
let _ = writeln!(&mut result, "database: {}", self.database);
let _ = writeln!(&mut result, "tracing: {}", self.tracing);
let _ = writeln!(&mut result, "pool size: {}", self.pool_size);
if !self.key_prefix.is_empty() {
let _ = writeln!(&mut result, "key prefix: {}", self.key_prefix);
}
if self.username.is_some() {
let _ = writeln!(&mut result, "username: ****");
}
if self.password.is_some() {
let _ = writeln!(&mut result, "password: ****");
}
result
}
}
impl Default for RedisConfig {
fn default() -> Self {
Self {
host: "localhost".to_string(),
port: 6380,
database: 0,
tracing: false,
pool_size: 8,
retries: RetryConfig::default(),
key_prefix: "".to_string(),
username: None,
password: None,
}
}
}
impl Default for RetryConfig {
fn default() -> Self {
Self::max_attempts_5()
}
}
impl RetryConfig {
pub fn max_attempts_3() -> RetryConfig {
Self {
max_attempts: 3,
min_delay: Duration::from_millis(100),
max_delay: Duration::from_secs(1),
multiplier: 3.0,
max_jitter_factor: Some(0.15),
}
}
pub fn max_attempts_5() -> RetryConfig {
Self {
max_attempts: 5,
min_delay: Duration::from_millis(100),
max_delay: Duration::from_secs(2),
multiplier: 2.0,
max_jitter_factor: Some(0.15),
}
}
pub fn no_retries() -> RetryConfig {
Self {
max_attempts: 0,
min_delay: Duration::from_millis(0),
max_delay: Duration::from_millis(0),
multiplier: 1.0,
max_jitter_factor: None,
}
}
}
pub fn env_config_provider() -> Env {
Env::prefixed(ENV_VAR_PREFIX).split(ENV_VAR_NESTED_SEPARATOR)
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type", content = "config")]
pub enum DbConfig {
Postgres(DbPostgresConfig),
Sqlite(DbSqliteConfig),
}
impl SafeDisplay for DbConfig {
fn to_safe_string(&self) -> String {
let mut result = String::new();
match self {
DbConfig::Postgres(postgres) => {
let _ = writeln!(&mut result, "postgres:");
let _ = writeln!(&mut result, "{}", postgres.to_safe_string_indented());
}
DbConfig::Sqlite(sqlite) => {
let _ = writeln!(&mut result, "sqlite:");
let _ = writeln!(&mut result, "{}", sqlite.to_safe_string_indented());
}
}
result
}
}
impl Default for DbConfig {
fn default() -> Self {
DbConfig::Sqlite(DbSqliteConfig {
database: "golem_service.db".to_string(),
max_connections: 10,
})
}
}
impl DbConfig {
pub fn postgres_example() -> Self {
Self::Postgres(DbPostgresConfig {
host: "localhost".to_string(),
database: "postgres".to_string(),
username: "postgres".to_string(),
password: "postgres".to_string(),
port: 5432,
max_connections: 10,
schema: None,
})
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DbSqliteConfig {
pub database: String,
pub max_connections: u32,
}
impl DbSqliteConfig {
#[cfg(feature = "sql")]
pub fn connect_options(&self) -> sqlx::sqlite::SqliteConnectOptions {
sqlx::sqlite::SqliteConnectOptions::new()
.filename(&self.database)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.create_if_missing(true)
}
}
impl SafeDisplay for DbSqliteConfig {
fn to_safe_string(&self) -> String {
let mut result = String::new();
let _ = writeln!(&mut result, "database: {}", self.database);
let _ = writeln!(&mut result, "max connections: {}", self.max_connections);
result
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DbPostgresConfig {
pub host: String,
pub database: String,
pub username: String,
pub password: String,
pub port: u16,
pub max_connections: u32,
pub schema: Option<String>,
}
impl DbPostgresConfig {
#[cfg(feature = "sql")]
pub fn connect_options(&self) -> sqlx::postgres::PgConnectOptions {
sqlx::postgres::PgConnectOptions::new()
.host(&self.host)
.port(self.port)
.database(&self.database)
.username(&self.username)
.password(&self.password)
}
}
impl SafeDisplay for DbPostgresConfig {
fn to_safe_string(&self) -> String {
let mut result = String::new();
let _ = writeln!(&mut result, "host: {}", self.host);
let _ = writeln!(&mut result, "port: {}", self.port);
let _ = writeln!(&mut result, "database: {}", self.database);
let _ = writeln!(&mut result, "username: ****");
let _ = writeln!(&mut result, "password: ****");
let _ = writeln!(&mut result, "max connections: {}", self.max_connections);
if let Some(schema) = &self.schema {
let _ = writeln!(&mut result, "schema: {schema}");
}
result
}
}