grapsus_config/multi_file/
loader.rs1use anyhow::{anyhow, Context, Result};
7use glob::glob;
8use kdl::KdlDocument;
9use std::collections::HashSet;
10use std::fs;
11use std::path::{Path, PathBuf};
12use tracing::{debug, info, warn};
13
14use crate::Config;
15
16use super::builder::{ConfigBuilder, PartialConfig};
17
18pub struct MultiFileLoader {
34 base_dir: PathBuf,
36 include_patterns: Vec<String>,
38 exclude_patterns: Vec<String>,
40 recursive: bool,
42 #[allow(dead_code)]
44 allow_duplicates: bool,
45 strict: bool,
47 loaded_files: HashSet<PathBuf>,
49}
50
51impl MultiFileLoader {
52 pub fn new(base_dir: impl AsRef<Path>) -> Self {
54 Self {
55 base_dir: base_dir.as_ref().to_path_buf(),
56 include_patterns: vec!["*.kdl".to_string()],
57 exclude_patterns: vec![],
58 recursive: true,
59 allow_duplicates: false,
60 strict: false,
61 loaded_files: HashSet::new(),
62 }
63 }
64
65 pub fn with_include(mut self, pattern: impl Into<String>) -> Self {
67 self.include_patterns.push(pattern.into());
68 self
69 }
70
71 pub fn with_exclude(mut self, pattern: impl Into<String>) -> Self {
73 self.exclude_patterns.push(pattern.into());
74 self
75 }
76
77 pub fn recursive(mut self, recursive: bool) -> Self {
79 self.recursive = recursive;
80 self
81 }
82
83 pub fn allow_duplicates(mut self, allow: bool) -> Self {
85 self.allow_duplicates = allow;
86 self
87 }
88
89 pub fn strict(mut self, strict: bool) -> Self {
91 self.strict = strict;
92 self
93 }
94
95 pub fn load(&mut self) -> Result<Config> {
97 info!("Loading configuration from directory: {:?}", self.base_dir);
98
99 let files = self.find_config_files()?;
101
102 if files.is_empty() {
103 return Err(anyhow!(
104 "No configuration files found in {:?}",
105 self.base_dir
106 ));
107 }
108
109 info!("Found {} configuration files", files.len());
110
111 let mut merged = ConfigBuilder::new();
113
114 for file in files {
115 self.load_file_recursive(&file, &mut merged)?;
116 }
117
118 let config = merged.build()?;
120
121 if self.strict {
123 config
124 .validate()
125 .map_err(|e| anyhow!("Validation failed: {}", e))?;
126 }
127
128 Ok(config)
129 }
130
131 fn load_file_recursive(&mut self, path: &Path, merged: &mut ConfigBuilder) -> Result<()> {
133 let canonical = path
135 .canonicalize()
136 .with_context(|| format!("Failed to resolve path: {:?}", path))?;
137
138 if self.loaded_files.contains(&canonical) {
140 debug!("Skipping already loaded file: {:?}", canonical);
141 return Ok(());
142 }
143
144 debug!("Loading configuration from: {:?}", path);
145
146 self.loaded_files.insert(canonical.clone());
148
149 let config = self.load_file(path)?;
151
152 for include_path in &config.includes {
154 let resolved = self.resolve_include_path(path, include_path)?;
155
156 if !resolved.exists() {
157 warn!(
158 "Include file not found: {:?} (referenced from {:?})",
159 resolved, path
160 );
161 continue;
162 }
163
164 self.load_file_recursive(&resolved, merged)?;
165 }
166
167 merged.merge(config)?;
169
170 Ok(())
171 }
172
173 fn resolve_include_path(&self, from_file: &Path, include: &Path) -> Result<PathBuf> {
175 if include.is_absolute() {
176 Ok(include.to_path_buf())
177 } else {
178 let base_dir = from_file
180 .parent()
181 .ok_or_else(|| anyhow!("Cannot determine parent directory of {:?}", from_file))?;
182 Ok(base_dir.join(include))
183 }
184 }
185
186 fn find_config_files(&self) -> Result<Vec<PathBuf>> {
188 let mut files = Vec::new();
189 let mut seen = HashSet::new();
190
191 for pattern in &self.include_patterns {
192 let full_pattern = if self.recursive {
193 self.base_dir.join("**").join(pattern)
194 } else {
195 self.base_dir.join(pattern)
196 };
197
198 let pattern_str = full_pattern
199 .to_str()
200 .ok_or_else(|| anyhow!("Invalid path pattern"))?;
201
202 for entry in glob(pattern_str).context("Failed to read glob pattern")? {
203 match entry {
204 Ok(path) => {
205 if path.is_file() {
206 if self.should_exclude(&path) {
208 debug!("Excluding file: {:?}", path);
209 continue;
210 }
211
212 if seen.insert(path.clone()) {
213 files.push(path);
214 }
215 }
216 }
218 Err(e) => {
219 warn!("Error accessing path: {}", e);
220 }
221 }
222 }
223 }
224
225 files.sort();
227
228 Ok(files)
229 }
230
231 fn should_exclude(&self, path: &Path) -> bool {
233 let path_str = path.to_string_lossy();
234
235 for pattern in &self.exclude_patterns {
236 if path_str.contains(pattern) {
237 return true;
238 }
239 }
240
241 if path_str.contains(".example.") || path_str.contains(".bak") || path_str.ends_with("~") {
243 return true;
244 }
245
246 false
247 }
248
249 fn load_file(&self, path: &Path) -> Result<PartialConfig> {
251 let content =
252 fs::read_to_string(path).with_context(|| format!("Failed to read file: {:?}", path))?;
253
254 let doc: KdlDocument = content
255 .parse()
256 .with_context(|| format!("Failed to parse KDL file: {:?}", path))?;
257
258 PartialConfig::from_kdl(doc, path)
259 }
260}