use crate::{OrthoError, OrthoResult};
#[cfg(feature = "yaml")]
use figment::providers::Yaml;
use figment::{
Figment,
providers::{Format, Toml},
};
#[cfg(feature = "json5")]
use figment_json5::Json5;
use std::collections::HashSet;
use std::error::Error;
use std::path::{Path, PathBuf};
fn file_error(
path: &Path,
err: impl Into<Box<dyn Error + Send + Sync>>,
) -> std::sync::Arc<OrthoError> {
std::sync::Arc::new(OrthoError::File {
path: path.to_path_buf(),
source: err.into(),
})
}
fn invalid_input(path: &Path, msg: impl Into<String>) -> std::sync::Arc<OrthoError> {
file_error(
path,
std::io::Error::new(std::io::ErrorKind::InvalidInput, msg.into()),
)
}
fn invalid_data(path: &Path, msg: impl Into<String>) -> std::sync::Arc<OrthoError> {
file_error(
path,
std::io::Error::new(std::io::ErrorKind::InvalidData, msg.into()),
)
}
fn not_found(path: &Path, msg: impl Into<String>) -> std::sync::Arc<OrthoError> {
file_error(
path,
std::io::Error::new(std::io::ErrorKind::NotFound, msg.into()),
)
}
pub fn canonicalise(p: &Path) -> OrthoResult<PathBuf> {
#[cfg(windows)]
{
dunce::canonicalize(p).map_err(|e| file_error(p, e))
}
#[cfg(not(windows))]
{
std::fs::canonicalize(p).map_err(|e| file_error(p, e))
}
}
fn parse_config_by_format(path: &Path, data: &str) -> OrthoResult<Figment> {
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase);
let figment = match ext.as_deref() {
Some("json" | "json5") => {
#[cfg(feature = "json5")]
{
Figment::from(Json5::string(data))
}
#[cfg(not(feature = "json5"))]
{
return Err::<_, std::sync::Arc<OrthoError>>(file_error(
path,
std::io::Error::other(
"json5 feature disabled: enable the 'json5' feature to support this file format",
),
));
}
}
Some("yaml" | "yml") => {
#[cfg(feature = "yaml")]
{
serde_yaml::from_str::<serde_yaml::Value>(data).map_err(|e| file_error(path, e))?;
Figment::from(Yaml::string(data))
}
#[cfg(not(feature = "yaml"))]
{
return Err::<_, std::sync::Arc<OrthoError>>(file_error(
path,
std::io::Error::other("yaml feature disabled"),
));
}
}
_ => {
toml::from_str::<toml::Value>(data).map_err(|e| file_error(path, e))?;
Figment::from(Toml::string(data))
}
};
Ok(figment)
}
fn get_extends(figment: &Figment, current_path: &Path) -> OrthoResult<Option<PathBuf>> {
match figment.find_value("extends") {
Ok(val) => {
let base = val.as_str().ok_or_else(|| {
let actual_type = match &val {
figment::value::Value::String(..) => "string",
figment::value::Value::Char(..) => "char",
figment::value::Value::Bool(..) => "bool",
figment::value::Value::Num(..) => "number",
figment::value::Value::Empty(..) => "null",
figment::value::Value::Dict(..) => "object",
figment::value::Value::Array(..) => "array",
};
invalid_data(
current_path,
format!("'extends' key must be a string, but found type: {actual_type}"),
)
})?;
if base.is_empty() {
return Err(invalid_data(
current_path,
"'extends' key must be a non-empty string",
));
}
Ok(Some(PathBuf::from(base)))
}
Err(e) if e.missing() => Ok(None),
Err(e) => Err(file_error(current_path, e)),
}
}
fn resolve_base_path(current_path: &Path, base: PathBuf) -> OrthoResult<PathBuf> {
let parent = current_path.parent().ok_or_else(|| {
invalid_input(
current_path,
"Cannot determine parent directory for config file when resolving 'extends'",
)
})?;
let base = if base.is_absolute() {
base
} else {
parent.join(base)
};
canonicalise(&base)
}
fn merge_parent(figment: Figment, parent_figment: Figment) -> Figment {
parent_figment.merge(figment)
}
fn process_extends(
mut figment: Figment,
current_path: &Path,
visited: &mut HashSet<PathBuf>,
stack: &mut Vec<PathBuf>,
) -> OrthoResult<Figment> {
if let Some(base) = get_extends(&figment, current_path)? {
let canonical = resolve_base_path(current_path, base)?;
if !canonical.is_file() {
return Err(invalid_input(
&canonical,
"extended path is not a regular file",
));
}
let Some(parent_fig) = load_config_file_inner(&canonical, visited, stack)? else {
return Err(not_found(
&canonical,
"extended file disappeared during load",
));
};
figment = merge_parent(figment, parent_fig);
}
Ok(figment)
}
pub fn load_config_file(path: &Path) -> OrthoResult<Option<Figment>> {
let mut visited = HashSet::new();
let mut stack = Vec::new();
load_config_file_inner(path, &mut visited, &mut stack)
}
fn load_config_file_inner(
path: &Path,
visited: &mut HashSet<PathBuf>,
stack: &mut Vec<PathBuf>,
) -> OrthoResult<Option<Figment>> {
if !path.is_file() {
return Ok(None);
}
let canonical = canonicalise(path)?;
if !visited.insert(canonical.clone()) {
let mut cycle: Vec<String> = stack.iter().map(|p| p.display().to_string()).collect();
cycle.push(canonical.display().to_string());
return Err(std::sync::Arc::new(OrthoError::CyclicExtends {
cycle: cycle.join(" -> "),
}));
}
stack.push(canonical.clone());
let result = (|| {
let data = std::fs::read_to_string(&canonical).map_err(|e| file_error(&canonical, e))?;
let figment = parse_config_by_format(&canonical, &data)?;
process_extends(figment, &canonical, visited, stack)
})();
visited.remove(&canonical);
stack.pop();
result.map(Some)
}
#[cfg(test)]
mod file_tests;