Skip to main content

cfgmatic_files/
loader.rs

1//! Configuration loader with rule-based discovery and merge support.
2
3use 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/// Configuration loader with rule-based discovery.
10///
11/// Provides advanced configuration loading with support for:
12/// - Rule-based file discovery
13/// - Multi-tier configuration merging
14/// - Fragment (conf.d) loading
15/// - Custom merge strategies
16///
17/// # Example
18///
19/// ```
20/// use cfgmatic_files::RuleBasedLoader;
21/// use cfgmatic_paths::{ConfigRuleSet, ConfigFileRule, TierSearchMode};
22/// use cfgmatic_merge::MergeBehavior;
23///
24/// let rules = ConfigRuleSet::builder()
25///     .main_file(ConfigFileRule::toml("config")
26///         .tiers(TierSearchMode::All)
27///         .required(true))
28///     .build();
29///
30/// let loader = RuleBasedLoader::new("myapp")
31///     .rules(rules)
32///     .merge_behavior(MergeBehavior::Deep);
33///
34/// // Load with discovery info
35/// match loader.load_with_discovery::<serde_json::Value>() {
36///     Ok((config, discovery)) => {
37///         println!("Loaded from {} files", discovery.file_count());
38///     }
39///     Err(e) => eprintln!("Error: {}", e),
40/// }
41/// ```
42#[derive(Debug, Clone)]
43pub struct RuleBasedLoader {
44    /// Application name.
45    app_name: String,
46
47    /// Rule set for discovery (optional).
48    rules: Option<cfgmatic_paths::ConfigRuleSet>,
49
50    /// Merge options.
51    merge_options: MergeOptions,
52}
53
54impl RuleBasedLoader {
55    /// Create a new loader for an application.
56    #[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    /// Set the rule set for file discovery.
66    #[must_use]
67    pub fn rules(mut self, rules: cfgmatic_paths::ConfigRuleSet) -> Self {
68        self.rules = Some(rules);
69        self
70    }
71
72    /// Set the merge behavior.
73    #[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    /// Set the array merge strategy.
80    #[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    /// Set full merge options.
87    #[must_use]
88    pub const fn merge_options(mut self, options: MergeOptions) -> Self {
89        self.merge_options = options;
90        self
91    }
92
93    /// Load configuration with discovery information.
94    ///
95    /// # Errors
96    ///
97    /// Returns an error if:
98    /// - Required files are not found
99    /// - Files cannot be read or parsed
100    /// - Merge fails in Error mode with conflicts
101    ///
102    /// # Example
103    ///
104    /// ```
105    /// use cfgmatic_files::RuleBasedLoader;
106    ///
107    /// let loader = RuleBasedLoader::new("myapp");
108    /// match loader.load_with_discovery::<serde_json::Value>() {
109    ///     Ok((config, discovery)) => {
110    ///         println!("Loaded config: {:#}", config);
111    ///         for path in discovery.all_paths() {
112    ///             println!("  - {}", path.display());
113    ///         }
114    ///     }
115    ///     Err(e) => eprintln!("Error: {}", e),
116    /// }
117    /// ```
118    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            // Default discovery
127            return self.load_default(&path_finder);
128        };
129
130        // Check required files
131        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    /// Load configuration (without discovery info).
151    ///
152    /// # Errors
153    ///
154    /// Returns an error if configuration cannot be loaded.
155    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    /// Load from default discovery (without rules).
164    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        // Convert to ConfigFile and load
188        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    /// Load configuration from a discovery result.
216    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        // Load main files in merge order (lowest tier first)
223        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        // Sort by tier (lowest first for merge order)
231        main_files.sort_by_key(|(c, _)| u8::from(c.tier));
232
233        // Merge main files
234        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        // Load and merge fragments
248        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        // Sort fragments by tier (lowest first)
256        fragments.sort_by_key(|(c, _)| u8::from(c.tier));
257
258        // Merge fragments
259        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    /// Parse a single file.
276    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
297/// Load configuration using rule-based discovery.
298///
299/// This is a convenience function that creates a loader with default settings.
300///
301/// # Errors
302///
303/// Returns an error if configuration cannot be loaded.
304///
305/// # Example
306///
307/// ```
308/// use cfgmatic_files::load_with_rules;
309/// use cfgmatic_paths::{ConfigRuleSet, ConfigFileRule};
310///
311/// let rules = ConfigRuleSet::builder()
312///     .main_file(ConfigFileRule::toml("config"))
313///     .build();
314///
315/// match load_with_rules::<serde_json::Value>("myapp", rules) {
316///     Ok(config) => println!("Loaded: {:#}", config),
317///     Err(e) => eprintln!("Error: {}", e),
318/// }
319/// ```
320pub 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        // Use a non-existent app to avoid finding actual config
370        let loader = RuleBasedLoader::new("nonexistent_test_app_12345");
371        let result: serde_json::Value = loader.load()?;
372        // Default for Value is Null, not empty object
373        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        // Note: This test doesn't actually test the full loader
389        // because we can't easily mock the PathFinder.
390        // It just verifies the loader can be constructed.
391        let loader = RuleBasedLoader::new("testapp");
392        assert_eq!(loader.app_name, "testapp");
393
394        Ok(())
395    }
396}