#![doc = include_str!("../README.md")]
mod error;
use crate::error::{
DeserializationError, Result, SerializationError, UniversalConfigError as Error,
UniversalConfigError,
};
use dirs::{config_dir, home_dir};
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::fs;
use std::path::{Path, PathBuf};
use tracing::debug;
pub enum Format {
#[cfg(feature = "json")]
Json,
#[cfg(feature = "yaml")]
Yaml,
#[cfg(feature = "toml")]
Toml,
#[cfg(feature = "corn")]
Corn,
#[cfg(feature = "xml")]
Xml,
#[cfg(feature = "ron")]
Ron,
}
impl Format {
const fn extension(&self) -> &str {
match self {
#[cfg(feature = "json")]
Self::Json => "json",
#[cfg(feature = "yaml")]
Self::Yaml => "yaml",
#[cfg(feature = "toml")]
Self::Toml => "toml",
#[cfg(feature = "corn")]
Self::Corn => "corn",
#[cfg(feature = "xml")]
Self::Xml => "xml",
#[cfg(feature = "ron")]
Self::Ron => "ron",
}
}
}
pub struct ConfigLoader<'a> {
app_name: &'a str,
file_name: &'a str,
formats: &'a [Format],
config_dir: Option<&'a str>,
}
impl<'a> ConfigLoader<'a> {
#[must_use]
pub const fn new(app_name: &'a str) -> ConfigLoader<'a> {
Self {
app_name,
file_name: "config",
formats: &[
#[cfg(feature = "json")]
Format::Json,
#[cfg(feature = "yaml")]
Format::Yaml,
#[cfg(feature = "toml")]
Format::Toml,
#[cfg(feature = "corn")]
Format::Corn,
#[cfg(feature = "xml")]
Format::Xml,
#[cfg(feature = "ron")]
Format::Ron,
],
config_dir: None,
}
}
#[must_use]
pub const fn with_file_name(mut self, file_name: &'a str) -> Self {
self.file_name = file_name;
self
}
#[must_use]
pub const fn with_formats(mut self, formats: &'a [Format]) -> Self {
self.formats = formats;
self
}
#[must_use]
pub const fn with_config_dir(mut self, dir: &'a str) -> Self {
self.config_dir = Some(dir);
self
}
pub fn find_and_load<T: DeserializeOwned>(&self) -> Result<T> {
let file = self.try_find_file()?;
debug!("Found file at: '{}", file.display());
Self::load(&file)
}
fn get_config_dir(&self) -> std::result::Result<PathBuf, UniversalConfigError> {
self.config_dir
.map(Into::into)
.or_else(|| config_dir().map(|dir| dir.join(self.app_name)))
.or_else(|| home_dir().map(|dir| dir.join(format!(".{}", self.app_name))))
.ok_or(Error::MissingUserDir)
}
fn try_find_file(&self) -> Result<PathBuf> {
let config_dir = self.get_config_dir()?;
let extensions = self.get_extensions();
debug!("Using config dir: {}", config_dir.display());
let file = extensions.into_iter().find_map(|extension| {
let full_path = config_dir.join(format!("{}.{extension}", self.file_name));
if Path::exists(&full_path) {
Some(full_path)
} else {
None
}
});
file.ok_or(Error::FileNotFound)
}
pub fn load<T: DeserializeOwned, P: AsRef<Path>>(path: P) -> Result<T> {
let str = fs::read_to_string(&path)?;
let extension = path
.as_ref()
.extension()
.unwrap_or_default()
.to_str()
.unwrap_or_default();
let config = Self::deserialize(&str, extension)?;
Ok(config)
}
fn get_extensions(&self) -> Vec<&'static str> {
let mut extensions = vec![];
for format in self.formats {
match format {
#[cfg(feature = "json")]
Format::Json => extensions.push("json"),
#[cfg(feature = "yaml")]
Format::Yaml => {
extensions.push("yaml");
extensions.push("yml");
}
#[cfg(feature = "toml")]
Format::Toml => extensions.push("toml"),
#[cfg(feature = "corn")]
Format::Corn => extensions.push("corn"),
#[cfg(feature = "xml")]
Format::Xml => extensions.push("xml"),
#[cfg(feature = "ron")]
Format::Ron => extensions.push("ron"),
}
}
extensions
}
fn deserialize<T: DeserializeOwned>(
str: &str,
extension: &str,
) -> std::result::Result<T, DeserializationError> {
let res = match extension {
#[cfg(feature = "json")]
"json" => serde_json::from_str(str).map_err(DeserializationError::from),
#[cfg(feature = "toml")]
"toml" => toml::from_str(str).map_err(DeserializationError::from),
#[cfg(feature = "yaml")]
"yaml" | "yml" => serde_yaml::from_str(str).map_err(DeserializationError::from),
#[cfg(feature = "corn")]
"corn" => libcorn::from_str(str).map_err(DeserializationError::from),
#[cfg(feature = "xml")]
"xml" => serde_xml_rs::from_str(str).map_err(DeserializationError::from),
#[cfg(feature = "ron")]
"ron" => ron::from_str(str).map_err(DeserializationError::from),
_ => Err(DeserializationError::UnsupportedExtension(
extension.to_string(),
)),
}?;
Ok(res)
}
pub fn save<T: Serialize>(&self, config: &T, format: &Format) -> Result<()> {
let str = match format {
#[cfg(feature = "json")]
Format::Json => serde_json::to_string_pretty(config).map_err(SerializationError::from),
#[cfg(feature = "yaml")]
Format::Yaml => serde_yaml::to_string(config).map_err(SerializationError::from),
#[cfg(feature = "toml")]
Format::Toml => toml::to_string_pretty(config).map_err(SerializationError::from),
#[cfg(feature = "corn")]
Format::Corn => Err(SerializationError::UnsupportedExtension("corn".to_string())),
#[cfg(feature = "xml")]
Format::Xml => serde_xml_rs::to_string(config).map_err(SerializationError::from),
#[cfg(feature = "ron")]
Format::Ron => ron::to_string(config).map_err(SerializationError::from),
}?;
let config_dir = self.get_config_dir()?;
let file_name = format!("{}.{}", self.file_name, format.extension());
let full_path = config_dir.join(file_name);
fs::create_dir_all(config_dir)?;
fs::write(full_path, str)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
#[derive(Deserialize)]
struct ConfigContents {
test: String,
}
#[test]
fn test_json() {
let res: ConfigContents = ConfigLoader::load("test_configs/config.json").unwrap();
assert_eq!(res.test, "hello world")
}
#[test]
fn test_yaml() {
let res: ConfigContents = ConfigLoader::load("test_configs/config.yaml").unwrap();
assert_eq!(res.test, "hello world")
}
#[test]
fn test_toml() {
let res: ConfigContents = ConfigLoader::load("test_configs/config.toml").unwrap();
assert_eq!(res.test, "hello world")
}
#[test]
fn test_corn() {
let res: ConfigContents = ConfigLoader::load("test_configs/config.corn").unwrap();
assert_eq!(res.test, "hello world")
}
#[test]
fn test_xml() {
let res: ConfigContents = ConfigLoader::load("test_configs/config.xml").unwrap();
assert_eq!(res.test, "hello world")
}
#[test]
fn test_ron() {
let res: ConfigContents = ConfigLoader::load("test_configs/config.ron").unwrap();
assert_eq!(res.test, "hello world")
}
#[test]
fn test_find_load() {
let config = ConfigLoader::new("universal-config");
let res: ConfigContents = config
.with_config_dir("test_configs")
.find_and_load()
.unwrap();
assert_eq!(res.test, "hello world")
}
}