use async_trait::async_trait;
use prefer::source::Source;
use prefer::{ConfigValue, Error, Result};
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct ConfigEntry {
pub format: String,
pub data: String,
}
#[async_trait]
pub trait ConfigLoader: Send + Sync {
async fn load_config(&self) -> Option<ConfigEntry>;
fn name(&self) -> &str;
}
pub struct DbSource<L: ConfigLoader> {
loader: L,
}
impl<L: ConfigLoader> DbSource<L> {
pub fn new(loader: L) -> Self {
Self { loader }
}
}
#[async_trait]
impl<L: ConfigLoader + 'static> Source for DbSource<L> {
async fn load(&self) -> Result<ConfigValue> {
let entry = match self.loader.load_config().await {
Some(entry) => entry,
None => return Ok(ConfigValue::Object(HashMap::new())),
};
parse_config_data(&entry.data, &entry.format)
}
fn name(&self) -> &str {
self.loader.name()
}
}
fn parse_config_data(data: &str, format: &str) -> Result<ConfigValue> {
match format.to_lowercase().as_str() {
"json" => parse_json(data),
"toml" => parse_toml(data),
"yaml" | "yml" => parse_yaml(data),
_ => parse_json(data), }
}
fn parse_json(data: &str) -> Result<ConfigValue> {
let value = jzon::parse(data).map_err(|e| Error::ParseError {
format: "json".to_string(),
path: std::path::PathBuf::from("<database>"),
source: e.to_string().into(),
})?;
Ok(json_to_config_value(value))
}
fn parse_toml(data: &str) -> Result<ConfigValue> {
let doc = data
.parse::<toml_edit::DocumentMut>()
.map_err(|e| Error::ParseError {
format: "toml".to_string(),
path: std::path::PathBuf::from("<database>"),
source: e.to_string().into(),
})?;
Ok(toml_to_config_value(doc.as_item()))
}
fn parse_yaml(data: &str) -> Result<ConfigValue> {
let docs = yaml_rust2::YamlLoader::load_from_str(data).map_err(|e| Error::ParseError {
format: "yaml".to_string(),
path: std::path::PathBuf::from("<database>"),
source: e.to_string().into(),
})?;
match docs.into_iter().next() {
Some(doc) => Ok(yaml_to_config_value(&doc)),
None => Ok(ConfigValue::Object(HashMap::new())),
}
}
fn json_to_config_value(value: jzon::JsonValue) -> ConfigValue {
use jzon::JsonValue;
match &value {
JsonValue::Null => ConfigValue::Null,
JsonValue::Boolean(b) => ConfigValue::Bool(*b),
JsonValue::Number(_) => {
if let Some(i) = value.as_i64() {
ConfigValue::Integer(i)
} else if let Some(f) = value.as_f64() {
ConfigValue::Float(f)
} else {
ConfigValue::Null
}
}
JsonValue::Short(s) => ConfigValue::String(s.to_string()),
JsonValue::String(s) => ConfigValue::String(s.clone()),
JsonValue::Array(arr) => {
ConfigValue::Array(arr.iter().cloned().map(json_to_config_value).collect())
}
JsonValue::Object(obj) => ConfigValue::Object(
obj.iter()
.map(|(k, v)| (k.to_string(), json_to_config_value(v.clone())))
.collect(),
),
}
}
fn toml_to_config_value(item: &toml_edit::Item) -> ConfigValue {
match item {
toml_edit::Item::None => ConfigValue::Null,
toml_edit::Item::Value(v) => toml_value_to_config_value(v),
toml_edit::Item::Table(t) => ConfigValue::Object(
t.iter()
.map(|(k, v)| (k.to_string(), toml_to_config_value(v)))
.collect(),
),
toml_edit::Item::ArrayOfTables(arr) => ConfigValue::Array(
arr.iter()
.map(|t| {
ConfigValue::Object(
t.iter()
.map(|(k, v)| (k.to_string(), toml_to_config_value(v)))
.collect(),
)
})
.collect(),
),
}
}
fn toml_value_to_config_value(value: &toml_edit::Value) -> ConfigValue {
match value {
toml_edit::Value::Boolean(b) => ConfigValue::Bool(*b.value()),
toml_edit::Value::Integer(i) => ConfigValue::Integer(*i.value()),
toml_edit::Value::Float(f) => ConfigValue::Float(*f.value()),
toml_edit::Value::String(s) => ConfigValue::String(s.value().to_string()),
toml_edit::Value::Datetime(dt) => ConfigValue::String(dt.to_string()),
toml_edit::Value::Array(arr) => {
ConfigValue::Array(arr.iter().map(toml_value_to_config_value).collect())
}
toml_edit::Value::InlineTable(t) => ConfigValue::Object(
t.iter()
.map(|(k, v)| (k.to_string(), toml_value_to_config_value(v)))
.collect(),
),
}
}
fn yaml_to_config_value(value: &yaml_rust2::Yaml) -> ConfigValue {
match value {
yaml_rust2::Yaml::Null => ConfigValue::Null,
yaml_rust2::Yaml::Boolean(b) => ConfigValue::Bool(*b),
yaml_rust2::Yaml::Integer(i) => ConfigValue::Integer(*i),
yaml_rust2::Yaml::Real(s) => s
.parse::<f64>()
.map(ConfigValue::Float)
.unwrap_or(ConfigValue::Null),
yaml_rust2::Yaml::String(s) => ConfigValue::String(s.clone()),
yaml_rust2::Yaml::Array(arr) => {
ConfigValue::Array(arr.iter().map(yaml_to_config_value).collect())
}
yaml_rust2::Yaml::Hash(map) => ConfigValue::Object(
map.iter()
.filter_map(|(k, v)| k.as_str().map(|s| (s.to_string(), yaml_to_config_value(v))))
.collect(),
),
yaml_rust2::Yaml::Alias(_) => ConfigValue::Null,
yaml_rust2::Yaml::BadValue => ConfigValue::Null,
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TestLoader {
entry: Option<ConfigEntry>,
}
#[async_trait]
impl ConfigLoader for TestLoader {
async fn load_config(&self) -> Option<ConfigEntry> {
self.entry.clone()
}
fn name(&self) -> &str {
"test"
}
}
#[tokio::test]
async fn test_load_json() {
let loader = TestLoader {
entry: Some(ConfigEntry {
format: "json".to_string(),
data: r#"{"key": "value", "num": 42}"#.to_string(),
}),
};
let source = DbSource::new(loader);
let config = source.load().await.unwrap();
assert_eq!(config.get("key").unwrap().as_str(), Some("value"));
assert_eq!(config.get("num").unwrap().as_i64(), Some(42));
}
#[tokio::test]
async fn test_load_toml() {
let loader = TestLoader {
entry: Some(ConfigEntry {
format: "toml".to_string(),
data: r#"
key = "value"
num = 42
"#
.to_string(),
}),
};
let source = DbSource::new(loader);
let config = source.load().await.unwrap();
assert_eq!(config.get("key").unwrap().as_str(), Some("value"));
assert_eq!(config.get("num").unwrap().as_i64(), Some(42));
}
#[tokio::test]
async fn test_load_yaml() {
let loader = TestLoader {
entry: Some(ConfigEntry {
format: "yaml".to_string(),
data: "key: value\nnum: 42".to_string(),
}),
};
let source = DbSource::new(loader);
let config = source.load().await.unwrap();
assert_eq!(config.get("key").unwrap().as_str(), Some("value"));
assert_eq!(config.get("num").unwrap().as_i64(), Some(42));
}
#[tokio::test]
async fn test_load_empty() {
let loader = TestLoader { entry: None };
let source = DbSource::new(loader);
let config = source.load().await.unwrap();
assert!(config.as_object().unwrap().is_empty());
}
#[tokio::test]
async fn test_nested_json() {
let loader = TestLoader {
entry: Some(ConfigEntry {
format: "json".to_string(),
data: r#"{"database": {"host": "localhost", "port": 5432}}"#.to_string(),
}),
};
let source = DbSource::new(loader);
let config = source.load().await.unwrap();
let db = config.get("database").unwrap();
assert_eq!(db.get("host").unwrap().as_str(), Some("localhost"));
assert_eq!(db.get("port").unwrap().as_i64(), Some(5432));
}
}