use anyhow::{anyhow, Context, Result};
use glob::glob;
use kdl::KdlDocument;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
use crate::Config;
use super::builder::{ConfigBuilder, PartialConfig};
pub struct MultiFileLoader {
base_dir: PathBuf,
include_patterns: Vec<String>,
exclude_patterns: Vec<String>,
recursive: bool,
#[allow(dead_code)]
allow_duplicates: bool,
strict: bool,
loaded_files: HashSet<PathBuf>,
}
impl MultiFileLoader {
pub fn new(base_dir: impl AsRef<Path>) -> Self {
Self {
base_dir: base_dir.as_ref().to_path_buf(),
include_patterns: vec!["*.kdl".to_string()],
exclude_patterns: vec![],
recursive: true,
allow_duplicates: false,
strict: false,
loaded_files: HashSet::new(),
}
}
pub fn with_include(mut self, pattern: impl Into<String>) -> Self {
self.include_patterns.push(pattern.into());
self
}
pub fn with_exclude(mut self, pattern: impl Into<String>) -> Self {
self.exclude_patterns.push(pattern.into());
self
}
pub fn recursive(mut self, recursive: bool) -> Self {
self.recursive = recursive;
self
}
pub fn allow_duplicates(mut self, allow: bool) -> Self {
self.allow_duplicates = allow;
self
}
pub fn strict(mut self, strict: bool) -> Self {
self.strict = strict;
self
}
pub fn load(&mut self) -> Result<Config> {
info!("Loading configuration from directory: {:?}", self.base_dir);
let files = self.find_config_files()?;
if files.is_empty() {
return Err(anyhow!(
"No configuration files found in {:?}",
self.base_dir
));
}
info!("Found {} configuration files", files.len());
let mut merged = ConfigBuilder::new();
for file in files {
self.load_file_recursive(&file, &mut merged)?;
}
let config = merged.build()?;
if self.strict {
config
.validate()
.map_err(|e| anyhow!("Validation failed: {}", e))?;
}
Ok(config)
}
fn load_file_recursive(&mut self, path: &Path, merged: &mut ConfigBuilder) -> Result<()> {
let canonical = path
.canonicalize()
.with_context(|| format!("Failed to resolve path: {:?}", path))?;
if self.loaded_files.contains(&canonical) {
debug!("Skipping already loaded file: {:?}", canonical);
return Ok(());
}
debug!("Loading configuration from: {:?}", path);
self.loaded_files.insert(canonical.clone());
let config = self.load_file(path)?;
for include_path in &config.includes {
let resolved = self.resolve_include_path(path, include_path)?;
if !resolved.exists() {
warn!(
"Include file not found: {:?} (referenced from {:?})",
resolved, path
);
continue;
}
self.load_file_recursive(&resolved, merged)?;
}
merged.merge(config)?;
Ok(())
}
fn resolve_include_path(&self, from_file: &Path, include: &Path) -> Result<PathBuf> {
if include.is_absolute() {
Ok(include.to_path_buf())
} else {
let base_dir = from_file
.parent()
.ok_or_else(|| anyhow!("Cannot determine parent directory of {:?}", from_file))?;
Ok(base_dir.join(include))
}
}
fn find_config_files(&self) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
let mut seen = HashSet::new();
for pattern in &self.include_patterns {
let full_pattern = if self.recursive {
self.base_dir.join("**").join(pattern)
} else {
self.base_dir.join(pattern)
};
let pattern_str = full_pattern
.to_str()
.ok_or_else(|| anyhow!("Invalid path pattern"))?;
for entry in glob(pattern_str).context("Failed to read glob pattern")? {
match entry {
Ok(path) => {
if path.is_file() {
if self.should_exclude(&path) {
debug!("Excluding file: {:?}", path);
continue;
}
if seen.insert(path.clone()) {
files.push(path);
}
}
}
Err(e) => {
warn!("Error accessing path: {}", e);
}
}
}
}
files.sort();
Ok(files)
}
fn should_exclude(&self, path: &Path) -> bool {
let path_str = path.to_string_lossy();
for pattern in &self.exclude_patterns {
if path_str.contains(pattern) {
return true;
}
}
if path_str.contains(".example.") || path_str.contains(".bak") || path_str.ends_with("~") {
return true;
}
false
}
fn load_file(&self, path: &Path) -> Result<PartialConfig> {
let content =
fs::read_to_string(path).with_context(|| format!("Failed to read file: {:?}", path))?;
let doc: KdlDocument = content
.parse()
.with_context(|| format!("Failed to parse KDL file: {:?}", path))?;
PartialConfig::from_kdl(doc, path)
}
}