use crate::{Format, Result};
use cfgmatic_merge::{ArrayMergeStrategy, Merge, MergeBehavior, MergeOptions};
use cfgmatic_paths::{ConfigCandidate, RuleBasedDiscovery};
use serde::de::DeserializeOwned;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct RuleBasedLoader {
app_name: String,
rules: Option<cfgmatic_paths::ConfigRuleSet>,
merge_options: MergeOptions,
}
impl RuleBasedLoader {
#[must_use]
pub fn new(app_name: impl Into<String>) -> Self {
Self {
app_name: app_name.into(),
rules: None,
merge_options: MergeOptions::new(),
}
}
#[must_use]
pub fn rules(mut self, rules: cfgmatic_paths::ConfigRuleSet) -> Self {
self.rules = Some(rules);
self
}
#[must_use]
pub fn merge_behavior(mut self, behavior: MergeBehavior) -> Self {
self.merge_options = MergeOptions::new().behavior(behavior);
self
}
#[must_use]
pub const fn array_strategy(mut self, strategy: ArrayMergeStrategy) -> Self {
self.merge_options = self.merge_options.array_strategy(strategy);
self
}
#[must_use]
pub const fn merge_options(mut self, options: MergeOptions) -> Self {
self.merge_options = options;
self
}
pub fn load_with_discovery<T>(&self) -> Result<(T, RuleBasedDiscovery)>
where
T: DeserializeOwned + Merge + Default,
{
let path_finder = cfgmatic_paths::PathsBuilder::new(&self.app_name).build();
let discovery = if let Some(rules) = &self.rules {
path_finder.discover_with_rules(rules)
} else {
return self.load_default(&path_finder);
};
if let Some(_missing) = discovery.missing_required() {
return Err(crate::FileError::NotFound {
pattern: "config".to_string(),
locations: format!(
"searched in: {}",
discovery
.all_paths()
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
),
});
}
let config = self.load_from_discovery(&discovery)?;
Ok((config, discovery))
}
pub fn load<T>(&self) -> Result<T>
where
T: DeserializeOwned + Merge + Default,
{
let (config, _) = self.load_with_discovery()?;
Ok(config)
}
fn load_default<T>(
&self,
path_finder: &cfgmatic_paths::PathFinder,
) -> Result<(T, RuleBasedDiscovery)>
where
T: DeserializeOwned + Merge + Default,
{
let candidates = path_finder.find_config_files(&cfgmatic_paths::FilePattern::extensions(
"config",
&["toml", "json"],
));
if candidates.is_empty() {
return Ok((
T::default(),
RuleBasedDiscovery {
rules: cfgmatic_paths::ConfigRuleSet::new(),
main_files: Vec::new(),
fragments: Vec::new(),
},
));
}
let mut result = T::default();
for candidate in &candidates {
if candidate.status.exists()
&& let Some(format) = Format::from_path(&candidate.path)
{
let value = Self::parse_file::<T>(&candidate.path, format)?;
result = Merge::merge(result, value, &self.merge_options).map_err(|e| {
crate::FileError::Parse {
path: candidate.path.clone(),
format: format.extension(),
source: Box::new(e) as Box<dyn std::error::Error + Send + Sync>,
}
})?;
}
}
Ok((
result,
RuleBasedDiscovery {
rules: cfgmatic_paths::ConfigRuleSet::new(),
main_files: vec![],
fragments: Vec::new(),
},
))
}
fn load_from_discovery<T>(&self, discovery: &RuleBasedDiscovery) -> Result<T>
where
T: DeserializeOwned + Merge + Default,
{
let mut result = T::default();
let mut main_files: Vec<(&ConfigCandidate, Format)> = Vec::new();
for candidate in discovery.main_candidates() {
if let Some(format) = Format::from_path(&candidate.path) {
main_files.push((candidate, format));
}
}
main_files.sort_by_key(|(c, _)| u8::from(c.tier));
for (candidate, format) in main_files {
if candidate.status.exists() {
let value = Self::parse_file::<T>(&candidate.path, format)?;
result = Merge::merge(result, value, &self.merge_options).map_err(|e| {
crate::FileError::Parse {
path: candidate.path.clone(),
format: format.extension(),
source: Box::new(e) as Box<dyn std::error::Error + Send + Sync>,
}
})?;
}
}
let mut fragments: Vec<(&ConfigCandidate, Format)> = Vec::new();
for candidate in discovery.fragment_candidates() {
if let Some(format) = Format::from_path(&candidate.path) {
fragments.push((candidate, format));
}
}
fragments.sort_by_key(|(c, _)| u8::from(c.tier));
for (candidate, format) in fragments {
if candidate.status.exists() {
let value = Self::parse_file::<T>(&candidate.path, format)?;
result = Merge::merge(result, value, &self.merge_options).map_err(|e| {
crate::FileError::Parse {
path: candidate.path.clone(),
format: format.extension(),
source: Box::new(e) as Box<dyn std::error::Error + Send + Sync>,
}
})?;
}
}
Ok(result)
}
fn parse_file<T>(path: &PathBuf, format: Format) -> std::result::Result<T, crate::FileError>
where
T: DeserializeOwned,
{
let content = std::fs::read_to_string(path).map_err(|e| {
crate::FileError::Io(std::io::Error::other(format!(
"Failed to read '{}': {}",
path.display(),
e
)))
})?;
format
.parse(&content, path)
.map_err(|e| crate::FileError::Parse {
path: path.clone(),
format: format.extension(),
source: Box::new(e) as Box<dyn std::error::Error + Send + Sync>,
})
}
}
pub fn load_with_rules<T>(
app_name: impl Into<String>,
rules: cfgmatic_paths::ConfigRuleSet,
) -> Result<T>
where
T: DeserializeOwned + Merge + Default,
{
RuleBasedLoader::new(app_name).rules(rules).load()
}
#[cfg(test)]
mod tests {
use super::*;
use cfgmatic_paths::{ConfigFileRule, ConfigRuleSet};
use std::fs;
use std::io::Write;
use tempfile::TempDir;
#[test]
fn test_loader_creation() {
let loader = RuleBasedLoader::new("testapp");
assert_eq!(loader.app_name, "testapp");
}
#[test]
fn test_loader_with_rules() {
let rules = ConfigRuleSet::builder()
.main_file(ConfigFileRule::toml("config"))
.build();
let loader = RuleBasedLoader::new("testapp").rules(rules);
assert!(loader.rules.is_some());
}
#[test]
fn test_loader_with_merge_options() {
let loader = RuleBasedLoader::new("testapp")
.merge_behavior(MergeBehavior::Deep)
.array_strategy(ArrayMergeStrategy::Append);
assert_eq!(loader.merge_options.behavior, MergeBehavior::Deep);
assert_eq!(
loader.merge_options.array_strategy,
ArrayMergeStrategy::Append
);
}
#[test]
fn test_load_empty_no_rules() -> Result<()> {
let loader = RuleBasedLoader::new("nonexistent_test_app_12345");
let result: serde_json::Value = loader.load()?;
assert!(result.is_null());
Ok(())
}
#[test]
fn test_load_with_temp_files() -> Result<()> {
let temp_dir = TempDir::new()?;
let config_dir = temp_dir.path().join("config");
fs::create_dir_all(&config_dir)?;
let config_file = config_dir.join("config.toml");
let mut file = std::fs::File::create(&config_file)?;
writeln!(file, "name = \"test\"")?;
writeln!(file, "value = 42")?;
let loader = RuleBasedLoader::new("testapp");
assert_eq!(loader.app_name, "testapp");
Ok(())
}
}