1use crate::{Format, Result};
4use cfgmatic_merge::{ArrayMergeStrategy, Merge, MergeBehavior, MergeOptions};
5use cfgmatic_paths::{ConfigCandidate, RuleBasedDiscovery};
6use serde::de::DeserializeOwned;
7use std::path::PathBuf;
8
9#[derive(Debug, Clone)]
43pub struct RuleBasedLoader {
44 app_name: String,
46
47 rules: Option<cfgmatic_paths::ConfigRuleSet>,
49
50 merge_options: MergeOptions,
52}
53
54impl RuleBasedLoader {
55 #[must_use]
57 pub fn new(app_name: impl Into<String>) -> Self {
58 Self {
59 app_name: app_name.into(),
60 rules: None,
61 merge_options: MergeOptions::new(),
62 }
63 }
64
65 #[must_use]
67 pub fn rules(mut self, rules: cfgmatic_paths::ConfigRuleSet) -> Self {
68 self.rules = Some(rules);
69 self
70 }
71
72 #[must_use]
74 pub fn merge_behavior(mut self, behavior: MergeBehavior) -> Self {
75 self.merge_options = MergeOptions::new().behavior(behavior);
76 self
77 }
78
79 #[must_use]
81 pub const fn array_strategy(mut self, strategy: ArrayMergeStrategy) -> Self {
82 self.merge_options = self.merge_options.array_strategy(strategy);
83 self
84 }
85
86 #[must_use]
88 pub const fn merge_options(mut self, options: MergeOptions) -> Self {
89 self.merge_options = options;
90 self
91 }
92
93 pub fn load_with_discovery<T>(&self) -> Result<(T, RuleBasedDiscovery)>
119 where
120 T: DeserializeOwned + Merge + Default,
121 {
122 let path_finder = cfgmatic_paths::PathsBuilder::new(&self.app_name).build();
123 let discovery = if let Some(rules) = &self.rules {
124 path_finder.discover_with_rules(rules)
125 } else {
126 return self.load_default(&path_finder);
128 };
129
130 if let Some(_missing) = discovery.missing_required() {
132 return Err(crate::FileError::NotFound {
133 pattern: "config".to_string(),
134 locations: format!(
135 "searched in: {}",
136 discovery
137 .all_paths()
138 .iter()
139 .map(|p| p.display().to_string())
140 .collect::<Vec<_>>()
141 .join(", ")
142 ),
143 });
144 }
145
146 let config = self.load_from_discovery(&discovery)?;
147 Ok((config, discovery))
148 }
149
150 pub fn load<T>(&self) -> Result<T>
156 where
157 T: DeserializeOwned + Merge + Default,
158 {
159 let (config, _) = self.load_with_discovery()?;
160 Ok(config)
161 }
162
163 fn load_default<T>(
165 &self,
166 path_finder: &cfgmatic_paths::PathFinder,
167 ) -> Result<(T, RuleBasedDiscovery)>
168 where
169 T: DeserializeOwned + Merge + Default,
170 {
171 let candidates = path_finder.find_config_files(&cfgmatic_paths::FilePattern::extensions(
172 "config",
173 &["toml", "json"],
174 ));
175
176 if candidates.is_empty() {
177 return Ok((
178 T::default(),
179 RuleBasedDiscovery {
180 rules: cfgmatic_paths::ConfigRuleSet::new(),
181 main_files: Vec::new(),
182 fragments: Vec::new(),
183 },
184 ));
185 }
186
187 let mut result = T::default();
189
190 for candidate in &candidates {
191 if candidate.status.exists()
192 && let Some(format) = Format::from_path(&candidate.path)
193 {
194 let value = Self::parse_file::<T>(&candidate.path, format)?;
195 result = Merge::merge(result, value, &self.merge_options).map_err(|e| {
196 crate::FileError::Parse {
197 path: candidate.path.clone(),
198 format: format.extension(),
199 source: Box::new(e) as Box<dyn std::error::Error + Send + Sync>,
200 }
201 })?;
202 }
203 }
204
205 Ok((
206 result,
207 RuleBasedDiscovery {
208 rules: cfgmatic_paths::ConfigRuleSet::new(),
209 main_files: vec![],
210 fragments: Vec::new(),
211 },
212 ))
213 }
214
215 fn load_from_discovery<T>(&self, discovery: &RuleBasedDiscovery) -> Result<T>
217 where
218 T: DeserializeOwned + Merge + Default,
219 {
220 let mut result = T::default();
221
222 let mut main_files: Vec<(&ConfigCandidate, Format)> = Vec::new();
224 for candidate in discovery.main_candidates() {
225 if let Some(format) = Format::from_path(&candidate.path) {
226 main_files.push((candidate, format));
227 }
228 }
229
230 main_files.sort_by_key(|(c, _)| u8::from(c.tier));
232
233 for (candidate, format) in main_files {
235 if candidate.status.exists() {
236 let value = Self::parse_file::<T>(&candidate.path, format)?;
237 result = Merge::merge(result, value, &self.merge_options).map_err(|e| {
238 crate::FileError::Parse {
239 path: candidate.path.clone(),
240 format: format.extension(),
241 source: Box::new(e) as Box<dyn std::error::Error + Send + Sync>,
242 }
243 })?;
244 }
245 }
246
247 let mut fragments: Vec<(&ConfigCandidate, Format)> = Vec::new();
249 for candidate in discovery.fragment_candidates() {
250 if let Some(format) = Format::from_path(&candidate.path) {
251 fragments.push((candidate, format));
252 }
253 }
254
255 fragments.sort_by_key(|(c, _)| u8::from(c.tier));
257
258 for (candidate, format) in fragments {
260 if candidate.status.exists() {
261 let value = Self::parse_file::<T>(&candidate.path, format)?;
262 result = Merge::merge(result, value, &self.merge_options).map_err(|e| {
263 crate::FileError::Parse {
264 path: candidate.path.clone(),
265 format: format.extension(),
266 source: Box::new(e) as Box<dyn std::error::Error + Send + Sync>,
267 }
268 })?;
269 }
270 }
271
272 Ok(result)
273 }
274
275 fn parse_file<T>(path: &PathBuf, format: Format) -> std::result::Result<T, crate::FileError>
277 where
278 T: DeserializeOwned,
279 {
280 let content = std::fs::read_to_string(path).map_err(|e| {
281 crate::FileError::Io(std::io::Error::other(format!(
282 "Failed to read '{}': {}",
283 path.display(),
284 e
285 )))
286 })?;
287 format
288 .parse(&content, path)
289 .map_err(|e| crate::FileError::Parse {
290 path: path.clone(),
291 format: format.extension(),
292 source: Box::new(e) as Box<dyn std::error::Error + Send + Sync>,
293 })
294 }
295}
296
297pub fn load_with_rules<T>(
321 app_name: impl Into<String>,
322 rules: cfgmatic_paths::ConfigRuleSet,
323) -> Result<T>
324where
325 T: DeserializeOwned + Merge + Default,
326{
327 RuleBasedLoader::new(app_name).rules(rules).load()
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use cfgmatic_paths::{ConfigFileRule, ConfigRuleSet};
334 use std::fs;
335 use std::io::Write;
336 use tempfile::TempDir;
337
338 #[test]
339 fn test_loader_creation() {
340 let loader = RuleBasedLoader::new("testapp");
341 assert_eq!(loader.app_name, "testapp");
342 }
343
344 #[test]
345 fn test_loader_with_rules() {
346 let rules = ConfigRuleSet::builder()
347 .main_file(ConfigFileRule::toml("config"))
348 .build();
349
350 let loader = RuleBasedLoader::new("testapp").rules(rules);
351 assert!(loader.rules.is_some());
352 }
353
354 #[test]
355 fn test_loader_with_merge_options() {
356 let loader = RuleBasedLoader::new("testapp")
357 .merge_behavior(MergeBehavior::Deep)
358 .array_strategy(ArrayMergeStrategy::Append);
359
360 assert_eq!(loader.merge_options.behavior, MergeBehavior::Deep);
361 assert_eq!(
362 loader.merge_options.array_strategy,
363 ArrayMergeStrategy::Append
364 );
365 }
366
367 #[test]
368 fn test_load_empty_no_rules() -> Result<()> {
369 let loader = RuleBasedLoader::new("nonexistent_test_app_12345");
371 let result: serde_json::Value = loader.load()?;
372 assert!(result.is_null());
374 Ok(())
375 }
376
377 #[test]
378 fn test_load_with_temp_files() -> Result<()> {
379 let temp_dir = TempDir::new()?;
380 let config_dir = temp_dir.path().join("config");
381 fs::create_dir_all(&config_dir)?;
382
383 let config_file = config_dir.join("config.toml");
384 let mut file = std::fs::File::create(&config_file)?;
385 writeln!(file, "name = \"test\"")?;
386 writeln!(file, "value = 42")?;
387
388 let loader = RuleBasedLoader::new("testapp");
392 assert_eq!(loader.app_name, "testapp");
393
394 Ok(())
395 }
396}