use camino::{Utf8Path, Utf8PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::error::{Error, Result};
const CONFIG_EXTENSIONS: &[&str] = &["toml", "yaml", "yml", "json"];
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum LogLevel {
Debug,
#[default]
Info,
Warn,
Error,
}
impl LogLevel {
pub const fn as_str(&self) -> &'static str {
match self {
Self::Debug => "debug",
Self::Info => "info",
Self::Warn => "warn",
Self::Error => "error",
}
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct ConfigSources {
#[serde(skip_serializing_if = "Option::is_none")]
pub project_file: Option<Utf8PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_file: Option<Utf8PathBuf>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub explicit_files: Vec<Utf8PathBuf>,
}
impl ConfigSources {
pub fn primary_file(&self) -> Option<&Utf8Path> {
self.explicit_files
.last()
.map(Utf8PathBuf::as_path)
.or(self.project_file.as_deref())
.or(self.user_file.as_deref())
}
}
const MERGE_DEPTH_LIMIT: usize = 64;
pub fn deep_merge(base: &mut Value, overlay: Value) -> Result<()> {
fn merge_inner(base: &mut Value, overlay: Value, depth: usize) -> Result<()> {
if depth > MERGE_DEPTH_LIMIT {
return Err(crate::Error::ConfigMergeDepth);
}
match (base, overlay) {
(Value::Object(base_map), Value::Object(overlay_map)) => {
for (key, value) in overlay_map {
merge_inner(base_map.entry(key).or_insert(Value::Null), value, depth + 1)?;
}
}
(base, overlay) => *base = overlay,
}
Ok(())
}
merge_inner(base, overlay, 0)
}
pub fn parse_toml(content: &str) -> Result<Value> {
let toml_value: toml::Value = toml::from_str(content).map_err(|e| Error::ConfigParse {
path: "<toml>".to_string(),
source: Box::new(e.into()),
})?;
serde_json::to_value(toml_value).map_err(Error::ConfigDeserialize)
}
pub fn parse_yaml(content: &str) -> Result<Value> {
serde_saphyr::from_str(content).map_err(|e| Error::ConfigParse {
path: "<yaml>".to_string(),
source: Box::new(e.into()),
})
}
pub fn parse_json(content: &str) -> Result<Value> {
serde_json::from_str(content).map_err(|e| Error::ConfigParse {
path: "<json>".to_string(),
source: Box::new(e.into()),
})
}
pub fn parse_file(path: &Utf8Path) -> Result<Value> {
let content = std::fs::read_to_string(path.as_str()).map_err(|e| Error::ConfigParse {
path: path.to_string(),
source: Box::new(e.into()),
})?;
match path.extension() {
Some("toml") => parse_toml(&content),
Some("yaml" | "yml") => parse_yaml(&content),
Some("json") => parse_json(&content),
_ => parse_toml(&content), }
.map_err(|e| match e {
Error::ConfigParse { source, .. } => Error::ConfigParse {
path: path.to_string(),
source,
},
other => other,
})
}
#[derive(Debug, Default)]
pub struct ConfigLoader {
app_name: String,
project_search_root: Option<Utf8PathBuf>,
include_user_config: bool,
boundary_marker: Option<String>,
explicit_files: Vec<Utf8PathBuf>,
}
impl ConfigLoader {
pub fn new(app_name: &str) -> Self {
Self {
app_name: app_name.to_string(),
project_search_root: None,
include_user_config: true,
boundary_marker: Some(".git".to_string()),
explicit_files: Vec::new(),
}
}
pub fn with_project_search<P: AsRef<Utf8Path>>(mut self, path: P) -> Self {
self.project_search_root = Some(path.as_ref().to_path_buf());
self
}
pub const fn with_user_config(mut self, include: bool) -> Self {
self.include_user_config = include;
self
}
pub fn with_boundary_marker<S: Into<String>>(mut self, marker: S) -> Self {
self.boundary_marker = Some(marker.into());
self
}
pub fn without_boundary_marker(mut self) -> Self {
self.boundary_marker = None;
self
}
pub fn with_file<P: AsRef<Utf8Path>>(mut self, path: P) -> Self {
self.explicit_files.push(path.as_ref().to_path_buf());
self
}
#[tracing::instrument(skip(self), fields(app = %self.app_name, search_root = ?self.project_search_root))]
pub fn load<C: serde::de::DeserializeOwned + Default + Serialize>(
self,
) -> Result<(C, ConfigSources)> {
tracing::debug!("loading configuration");
let mut merged = serde_json::to_value(C::default()).map_err(Error::ConfigDeserialize)?;
let mut sources = ConfigSources::default();
if self.include_user_config
&& let Some(user_config) = self.find_user_config()
{
tracing::debug!(path = %user_config, "discovered user config");
let value = parse_file(&user_config)?;
deep_merge(&mut merged, value)?;
sources.user_file = Some(user_config);
}
if let Some(ref root) = self.project_search_root
&& let Some(project_config) = self.find_project_config(root)
{
tracing::debug!(path = %project_config, "discovered project config");
let value = parse_file(&project_config)?;
deep_merge(&mut merged, value)?;
sources.project_file = Some(project_config);
}
for file in &self.explicit_files {
tracing::debug!(path = %file, "loading explicit config");
let value = parse_file(file)?;
deep_merge(&mut merged, value)?;
}
sources.explicit_files = self.explicit_files;
let config: C = serde_json::from_value(merged).map_err(Error::ConfigDeserialize)?;
tracing::info!("configuration loaded");
Ok((config, sources))
}
pub fn load_or_error<C: serde::de::DeserializeOwned + Default + Serialize>(
&self,
) -> Result<(C, ConfigSources)> {
let has_user = self.include_user_config && self.find_user_config().is_some();
let has_project = self
.project_search_root
.as_ref()
.and_then(|root| self.find_project_config(root))
.is_some();
let has_explicit = !self.explicit_files.is_empty();
if !has_user && !has_project && !has_explicit {
return Err(Error::ConfigNotFound);
}
Self {
app_name: self.app_name.clone(),
project_search_root: self.project_search_root.clone(),
include_user_config: self.include_user_config,
boundary_marker: self.boundary_marker.clone(),
explicit_files: self.explicit_files.clone(),
}
.load()
}
fn find_project_config(&self, start: &Utf8Path) -> Option<Utf8PathBuf> {
let mut current = Some(start.to_path_buf());
while let Some(dir) = current {
for ext in CONFIG_EXTENSIONS {
let dotconfig = dir.join(format!(".config/{}.{ext}", self.app_name));
if dotconfig.is_file() {
return Some(dotconfig);
}
let dotfile = dir.join(format!(".{}.{ext}", self.app_name));
if dotfile.is_file() {
return Some(dotfile);
}
let regular = dir.join(format!("{}.{ext}", self.app_name));
if regular.is_file() {
return Some(regular);
}
}
if let Some(ref marker) = self.boundary_marker
&& dir.join(marker).exists()
&& dir != start
{
break;
}
current = dir.parent().map(Utf8Path::to_path_buf);
}
None
}
fn find_user_config(&self) -> Option<Utf8PathBuf> {
let proj_dirs = directories::ProjectDirs::from("", "", &self.app_name)?;
let config_dir = proj_dirs.config_dir();
for ext in CONFIG_EXTENSIONS {
let config_path = config_dir.join(format!("config.{ext}"));
if config_path.is_file() {
return Utf8PathBuf::from_path_buf(config_path).ok();
}
}
None
}
}
pub fn user_config_dir(app_name: &str) -> Option<Utf8PathBuf> {
let proj_dirs = directories::ProjectDirs::from("", "", app_name)?;
Utf8PathBuf::from_path_buf(proj_dirs.config_dir().to_path_buf()).ok()
}
pub fn user_cache_dir(app_name: &str) -> Option<Utf8PathBuf> {
let proj_dirs = directories::ProjectDirs::from("", "", app_name)?;
Utf8PathBuf::from_path_buf(proj_dirs.cache_dir().to_path_buf()).ok()
}
pub fn user_data_dir(app_name: &str) -> Option<Utf8PathBuf> {
let proj_dirs = directories::ProjectDirs::from("", "", app_name)?;
Utf8PathBuf::from_path_buf(proj_dirs.data_dir().to_path_buf()).ok()
}