Skip to main content

mabi_core/config/
loader.rs

1//! Configuration file loading with multiple format support.
2//!
3//! This module provides a unified configuration loading system that supports:
4//! - YAML files (.yaml, .yml)
5//! - JSON files (.json)
6//! - TOML files (.toml)
7//! - Automatic format detection based on file extension
8//! - Format-agnostic loading with explicit format specification
9//!
10//! # Example
11//!
12//! ```rust,ignore
13//! use mabi_core::config::loader::{ConfigLoader, ConfigFormat};
14//!
15//! // Auto-detect format from extension
16//! let config: EngineConfig = ConfigLoader::load("config.yaml")?;
17//!
18//! // Explicit format
19//! let config: EngineConfig = ConfigLoader::load_with_format("config", ConfigFormat::Toml)?;
20//!
21//! // Load from string
22//! let yaml_content = "max_devices: 10000";
23//! let config: EngineConfig = ConfigLoader::parse(yaml_content, ConfigFormat::Yaml)?;
24//! ```
25
26use std::fs;
27use std::io::Read;
28use std::path::{Path, PathBuf};
29
30use serde::de::DeserializeOwned;
31use serde::Serialize;
32
33use crate::error::Error;
34use crate::Result;
35
36/// Supported configuration file formats.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub enum ConfigFormat {
39    /// YAML format (.yaml, .yml)
40    Yaml,
41    /// JSON format (.json)
42    Json,
43    /// TOML format (.toml)
44    Toml,
45}
46
47impl ConfigFormat {
48    /// Get format from file extension.
49    pub fn from_extension(ext: &str) -> Option<Self> {
50        match ext.to_lowercase().as_str() {
51            "yaml" | "yml" => Some(Self::Yaml),
52            "json" => Some(Self::Json),
53            "toml" => Some(Self::Toml),
54            _ => None,
55        }
56    }
57
58    /// Get format from file path.
59    pub fn from_path(path: impl AsRef<Path>) -> Option<Self> {
60        path.as_ref()
61            .extension()
62            .and_then(|e| e.to_str())
63            .and_then(Self::from_extension)
64    }
65
66    /// Get the primary file extension for this format.
67    pub fn extension(&self) -> &'static str {
68        match self {
69            Self::Yaml => "yaml",
70            Self::Json => "json",
71            Self::Toml => "toml",
72        }
73    }
74
75    /// Get all valid extensions for this format.
76    pub fn extensions(&self) -> &'static [&'static str] {
77        match self {
78            Self::Yaml => &["yaml", "yml"],
79            Self::Json => &["json"],
80            Self::Toml => &["toml"],
81        }
82    }
83
84    /// Get MIME type for this format.
85    pub fn mime_type(&self) -> &'static str {
86        match self {
87            Self::Yaml => "application/x-yaml",
88            Self::Json => "application/json",
89            Self::Toml => "application/toml",
90        }
91    }
92}
93
94impl std::fmt::Display for ConfigFormat {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        match self {
97            Self::Yaml => write!(f, "YAML"),
98            Self::Json => write!(f, "JSON"),
99            Self::Toml => write!(f, "TOML"),
100        }
101    }
102}
103
104/// Configuration loader with format detection and parsing.
105pub struct ConfigLoader;
106
107impl ConfigLoader {
108    /// Load configuration from a file with automatic format detection.
109    ///
110    /// Format is determined by file extension:
111    /// - `.yaml`, `.yml` -> YAML
112    /// - `.json` -> JSON
113    /// - `.toml` -> TOML
114    ///
115    /// # Errors
116    ///
117    /// Returns error if:
118    /// - File cannot be read
119    /// - Format cannot be determined from extension
120    /// - Content fails to parse
121    pub fn load<T: DeserializeOwned>(path: impl AsRef<Path>) -> Result<T> {
122        let path = path.as_ref();
123        let format = ConfigFormat::from_path(path).ok_or_else(|| {
124            Error::Config(format!(
125                "Cannot determine format from file extension: {}",
126                path.display()
127            ))
128        })?;
129
130        Self::load_with_format(path, format)
131    }
132
133    /// Load configuration from a file with explicit format.
134    pub fn load_with_format<T: DeserializeOwned>(
135        path: impl AsRef<Path>,
136        format: ConfigFormat,
137    ) -> Result<T> {
138        let path = path.as_ref();
139        let content = fs::read_to_string(path)?;
140        Self::parse(&content, format)
141    }
142
143    /// Load configuration from a reader with explicit format.
144    pub fn load_from_reader<T: DeserializeOwned, R: Read>(
145        reader: &mut R,
146        format: ConfigFormat,
147    ) -> Result<T> {
148        let mut content = String::new();
149        reader.read_to_string(&mut content)?;
150        Self::parse(&content, format)
151    }
152
153    /// Parse configuration from a string with specified format.
154    pub fn parse<T: DeserializeOwned>(content: &str, format: ConfigFormat) -> Result<T> {
155        match format {
156            ConfigFormat::Yaml => {
157                serde_yaml::from_str(content).map_err(|e| Error::Serialization(e.to_string()))
158            }
159            ConfigFormat::Json => {
160                serde_json::from_str(content).map_err(|e| Error::Serialization(e.to_string()))
161            }
162            ConfigFormat::Toml => {
163                toml::from_str(content).map_err(|e| Error::Serialization(e.to_string()))
164            }
165        }
166    }
167
168    /// Serialize configuration to a string.
169    pub fn serialize<T: Serialize>(config: &T, format: ConfigFormat) -> Result<String> {
170        match format {
171            ConfigFormat::Yaml => {
172                serde_yaml::to_string(config).map_err(|e| Error::Serialization(e.to_string()))
173            }
174            ConfigFormat::Json => serde_json::to_string_pretty(config)
175                .map_err(|e| Error::Serialization(e.to_string())),
176            ConfigFormat::Toml => {
177                toml::to_string_pretty(config).map_err(|e| Error::Serialization(e.to_string()))
178            }
179        }
180    }
181
182    /// Save configuration to a file with automatic format detection.
183    pub fn save<T: Serialize>(config: &T, path: impl AsRef<Path>) -> Result<()> {
184        let path = path.as_ref();
185        let format = ConfigFormat::from_path(path).ok_or_else(|| {
186            Error::Config(format!(
187                "Cannot determine format from file extension: {}",
188                path.display()
189            ))
190        })?;
191
192        Self::save_with_format(config, path, format)
193    }
194
195    /// Save configuration to a file with explicit format.
196    pub fn save_with_format<T: Serialize>(
197        config: &T,
198        path: impl AsRef<Path>,
199        format: ConfigFormat,
200    ) -> Result<()> {
201        let content = Self::serialize(config, format)?;
202        fs::write(path, content)?;
203        Ok(())
204    }
205
206    /// Try to load configuration from multiple paths, returning the first success.
207    ///
208    /// Useful for loading from default locations:
209    /// ```rust,ignore
210    /// let config: EngineConfig = ConfigLoader::load_first(&[
211    ///     "config.yaml",
212    ///     "config.json",
213    ///     "/etc/trap-sim/config.yaml",
214    /// ])?;
215    /// ```
216    pub fn load_first<T: DeserializeOwned>(paths: &[impl AsRef<Path>]) -> Result<(T, PathBuf)> {
217        let mut last_error = None;
218
219        for path in paths {
220            let path = path.as_ref();
221            if path.exists() {
222                match Self::load(path) {
223                    Ok(config) => return Ok((config, path.to_path_buf())),
224                    Err(e) => last_error = Some(e),
225                }
226            }
227        }
228
229        Err(last_error.unwrap_or_else(|| {
230            Error::Config("No configuration file found in any of the specified paths".to_string())
231        }))
232    }
233
234    /// Check if a file format is supported.
235    pub fn is_supported(path: impl AsRef<Path>) -> bool {
236        ConfigFormat::from_path(path).is_some()
237    }
238}
239
240/// Configuration file discovery.
241pub struct ConfigDiscovery {
242    /// Base name for configuration files (without extension).
243    base_name: String,
244    /// Search directories in order of precedence.
245    search_dirs: Vec<PathBuf>,
246    /// Preferred formats in order.
247    formats: Vec<ConfigFormat>,
248}
249
250impl ConfigDiscovery {
251    /// Create a new configuration discovery with default settings.
252    pub fn new(base_name: impl Into<String>) -> Self {
253        Self {
254            base_name: base_name.into(),
255            search_dirs: Vec::new(),
256            formats: vec![ConfigFormat::Yaml, ConfigFormat::Toml, ConfigFormat::Json],
257        }
258    }
259
260    /// Add a search directory.
261    pub fn search_dir(mut self, dir: impl Into<PathBuf>) -> Self {
262        self.search_dirs.push(dir.into());
263        self
264    }
265
266    /// Add multiple search directories.
267    pub fn search_dirs(mut self, dirs: impl IntoIterator<Item = impl Into<PathBuf>>) -> Self {
268        self.search_dirs.extend(dirs.into_iter().map(Into::into));
269        self
270    }
271
272    /// Set preferred formats.
273    pub fn formats(mut self, formats: Vec<ConfigFormat>) -> Self {
274        self.formats = formats;
275        self
276    }
277
278    /// Add standard search locations.
279    ///
280    /// Includes:
281    /// - Current directory
282    /// - ./config/
283    /// - ~/.config/{app_name}/
284    /// - /etc/{app_name}/
285    pub fn with_standard_dirs(mut self, app_name: &str) -> Self {
286        // Current directory
287        self.search_dirs.push(PathBuf::from("."));
288
289        // ./config/
290        self.search_dirs.push(PathBuf::from("./config"));
291
292        // User config directory
293        if let Some(home) = dirs_home() {
294            self.search_dirs.push(home.join(".config").join(app_name));
295        }
296
297        // System config directory (Unix)
298        #[cfg(unix)]
299        {
300            self.search_dirs.push(PathBuf::from("/etc").join(app_name));
301        }
302
303        self
304    }
305
306    /// Find all candidate configuration file paths.
307    pub fn candidates(&self) -> Vec<PathBuf> {
308        let mut candidates = Vec::new();
309
310        for dir in &self.search_dirs {
311            for format in &self.formats {
312                for ext in format.extensions() {
313                    let path = dir.join(format!("{}.{}", self.base_name, ext));
314                    candidates.push(path);
315                }
316            }
317        }
318
319        candidates
320    }
321
322    /// Find the first existing configuration file.
323    pub fn find(&self) -> Option<PathBuf> {
324        self.candidates().into_iter().find(|p| p.exists())
325    }
326
327    /// Load configuration from the first found file.
328    pub fn load<T: DeserializeOwned>(&self) -> Result<(T, PathBuf)> {
329        let candidates = self.candidates();
330        ConfigLoader::load_first(&candidates)
331    }
332}
333
334/// Get home directory (cross-platform).
335fn dirs_home() -> Option<PathBuf> {
336    #[cfg(unix)]
337    {
338        std::env::var("HOME").ok().map(PathBuf::from)
339    }
340    #[cfg(windows)]
341    {
342        std::env::var("USERPROFILE").ok().map(PathBuf::from)
343    }
344    #[cfg(not(any(unix, windows)))]
345    {
346        None
347    }
348}
349
350/// Builder for creating layered configuration.
351///
352/// Loads configuration from multiple sources with later sources
353/// overriding earlier ones.
354pub struct LayeredConfigBuilder<T> {
355    base: T,
356    loaded_from: Vec<PathBuf>,
357}
358
359impl<T: DeserializeOwned + Default + Clone> LayeredConfigBuilder<T> {
360    /// Create with default base configuration.
361    pub fn new() -> Self {
362        Self {
363            base: T::default(),
364            loaded_from: Vec::new(),
365        }
366    }
367
368    /// Create with a specific base configuration.
369    pub fn with_base(base: T) -> Self {
370        Self {
371            base,
372            loaded_from: Vec::new(),
373        }
374    }
375}
376
377impl<T: DeserializeOwned + Clone> LayeredConfigBuilder<T> {
378    /// Load and merge from a file if it exists.
379    pub fn load_optional(mut self, path: impl AsRef<Path>) -> Self {
380        let path = path.as_ref();
381        if path.exists() {
382            if let Ok(config) = ConfigLoader::load::<T>(path) {
383                self.base = config;
384                self.loaded_from.push(path.to_path_buf());
385            }
386        }
387        self
388    }
389
390    /// Load and merge from a file (error if not found).
391    pub fn load(mut self, path: impl AsRef<Path>) -> Result<Self> {
392        let path = path.as_ref();
393        self.base = ConfigLoader::load(path)?;
394        self.loaded_from.push(path.to_path_buf());
395        Ok(self)
396    }
397
398    /// Get the list of files that were loaded.
399    pub fn loaded_from(&self) -> &[PathBuf] {
400        &self.loaded_from
401    }
402
403    /// Build the final configuration.
404    pub fn build(self) -> T {
405        self.base
406    }
407}
408
409impl<T: DeserializeOwned + Default + Clone> Default for LayeredConfigBuilder<T> {
410    fn default() -> Self {
411        Self::new()
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use serde::{Deserialize, Serialize};
419    use std::io::Cursor;
420
421    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
422    struct TestConfig {
423        name: String,
424        #[serde(default)]
425        count: u32,
426    }
427
428    #[test]
429    fn test_format_from_extension() {
430        assert_eq!(
431            ConfigFormat::from_extension("yaml"),
432            Some(ConfigFormat::Yaml)
433        );
434        assert_eq!(
435            ConfigFormat::from_extension("yml"),
436            Some(ConfigFormat::Yaml)
437        );
438        assert_eq!(
439            ConfigFormat::from_extension("json"),
440            Some(ConfigFormat::Json)
441        );
442        assert_eq!(
443            ConfigFormat::from_extension("toml"),
444            Some(ConfigFormat::Toml)
445        );
446        assert_eq!(ConfigFormat::from_extension("txt"), None);
447    }
448
449    #[test]
450    fn test_format_from_path() {
451        assert_eq!(
452            ConfigFormat::from_path("config.yaml"),
453            Some(ConfigFormat::Yaml)
454        );
455        assert_eq!(
456            ConfigFormat::from_path("/etc/app/config.toml"),
457            Some(ConfigFormat::Toml)
458        );
459        assert_eq!(
460            ConfigFormat::from_path("data.json"),
461            Some(ConfigFormat::Json)
462        );
463        assert_eq!(ConfigFormat::from_path("noext"), None);
464    }
465
466    #[test]
467    fn test_parse_yaml() {
468        let yaml = r#"
469name: test
470count: 42
471"#;
472        let config: TestConfig = ConfigLoader::parse(yaml, ConfigFormat::Yaml).unwrap();
473        assert_eq!(config.name, "test");
474        assert_eq!(config.count, 42);
475    }
476
477    #[test]
478    fn test_parse_json() {
479        let json = r#"{"name": "test", "count": 42}"#;
480        let config: TestConfig = ConfigLoader::parse(json, ConfigFormat::Json).unwrap();
481        assert_eq!(config.name, "test");
482        assert_eq!(config.count, 42);
483    }
484
485    #[test]
486    fn test_parse_toml() {
487        let toml = r#"
488name = "test"
489count = 42
490"#;
491        let config: TestConfig = ConfigLoader::parse(toml, ConfigFormat::Toml).unwrap();
492        assert_eq!(config.name, "test");
493        assert_eq!(config.count, 42);
494    }
495
496    #[test]
497    fn test_serialize_yaml() {
498        let config = TestConfig {
499            name: "test".to_string(),
500            count: 42,
501        };
502        let yaml = ConfigLoader::serialize(&config, ConfigFormat::Yaml).unwrap();
503        assert!(yaml.contains("name: test"));
504    }
505
506    #[test]
507    fn test_serialize_json() {
508        let config = TestConfig {
509            name: "test".to_string(),
510            count: 42,
511        };
512        let json = ConfigLoader::serialize(&config, ConfigFormat::Json).unwrap();
513        assert!(json.contains("\"name\": \"test\""));
514    }
515
516    #[test]
517    fn test_serialize_toml() {
518        let config = TestConfig {
519            name: "test".to_string(),
520            count: 42,
521        };
522        let toml = ConfigLoader::serialize(&config, ConfigFormat::Toml).unwrap();
523        assert!(toml.contains("name = \"test\""));
524    }
525
526    #[test]
527    fn test_load_from_reader() {
528        let yaml = "name: reader_test\ncount: 100\n";
529        let mut reader = Cursor::new(yaml);
530        let config: TestConfig =
531            ConfigLoader::load_from_reader(&mut reader, ConfigFormat::Yaml).unwrap();
532        assert_eq!(config.name, "reader_test");
533        assert_eq!(config.count, 100);
534    }
535
536    #[test]
537    fn test_is_supported() {
538        assert!(ConfigLoader::is_supported("config.yaml"));
539        assert!(ConfigLoader::is_supported("config.yml"));
540        assert!(ConfigLoader::is_supported("config.json"));
541        assert!(ConfigLoader::is_supported("config.toml"));
542        assert!(!ConfigLoader::is_supported("config.txt"));
543        assert!(!ConfigLoader::is_supported("config"));
544    }
545
546    #[test]
547    fn test_config_discovery_candidates() {
548        let discovery = ConfigDiscovery::new("config")
549            .search_dir("/etc/app")
550            .search_dir(".");
551
552        let candidates = discovery.candidates();
553
554        // Should have candidates for each dir × format × extension
555        assert!(candidates
556            .iter()
557            .any(|p| p.to_string_lossy().contains("config.yaml")));
558        assert!(candidates
559            .iter()
560            .any(|p| p.to_string_lossy().contains("config.toml")));
561        assert!(candidates
562            .iter()
563            .any(|p| p.to_string_lossy().contains("config.json")));
564    }
565
566    #[test]
567    fn test_layered_config_builder() {
568        let config = LayeredConfigBuilder::<TestConfig>::new().build();
569        assert_eq!(config.name, "");
570        assert_eq!(config.count, 0);
571    }
572
573    #[test]
574    fn test_format_display() {
575        assert_eq!(format!("{}", ConfigFormat::Yaml), "YAML");
576        assert_eq!(format!("{}", ConfigFormat::Json), "JSON");
577        assert_eq!(format!("{}", ConfigFormat::Toml), "TOML");
578    }
579
580    #[test]
581    fn test_format_mime_type() {
582        assert_eq!(ConfigFormat::Yaml.mime_type(), "application/x-yaml");
583        assert_eq!(ConfigFormat::Json.mime_type(), "application/json");
584        assert_eq!(ConfigFormat::Toml.mime_type(), "application/toml");
585    }
586}