Skip to main content

backbone_core/config/
loader.rs

1//! Configuration file loader with environment variable substitution
2//!
3//! Supports YAML, TOML, and JSON formats with `${VAR:default}` syntax.
4
5use super::{BackboneConfig, ConfigError, ConfigResult};
6use std::path::Path;
7
8/// Configuration file loader
9pub struct ConfigLoader;
10
11impl ConfigLoader {
12    /// Load configuration from a file
13    ///
14    /// Automatically detects format from file extension:
15    /// - `.yml`, `.yaml` → YAML
16    /// - `.toml` → TOML
17    /// - `.json` → JSON
18    ///
19    /// Environment variables in `${VAR}` or `${VAR:default}` format
20    /// are substituted before parsing.
21    pub fn load_file<P: AsRef<Path>>(path: P) -> ConfigResult<BackboneConfig> {
22        let path = path.as_ref();
23
24        // Check file exists
25        if !path.exists() {
26            return Err(ConfigError::file_not_found(path));
27        }
28
29        // Read file content
30        let content = std::fs::read_to_string(path)
31            .map_err(|e| ConfigError::read_error(path, e))?;
32
33        // Substitute environment variables
34        let content = Self::substitute_env_vars(&content)?;
35
36        // Parse based on extension
37        let extension = path
38            .extension()
39            .and_then(|e| e.to_str())
40            .unwrap_or("");
41
42        let config = match extension {
43            "yml" | "yaml" => serde_yaml::from_str(&content)?,
44            "toml" => toml::from_str(&content)?,
45            "json" => serde_json::from_str(&content)?,
46            _ => return Err(ConfigError::unsupported_format(extension)),
47        };
48
49        Ok(config)
50    }
51
52    /// Load configuration with environment-specific overrides
53    ///
54    /// 1. Loads base config from `{base_path}`
55    /// 2. If `{base_path}-{env}.{ext}` exists, merges it
56    ///
57    /// # Example
58    ///
59    /// ```ignore
60    /// // Loads config/application.yml
61    /// // Then merges config/application-production.yml if it exists
62    /// let config = ConfigLoader::load_with_env("config/application.yml", "production")?;
63    /// ```
64    pub fn load_with_env<P: AsRef<Path>>(base_path: P, env: &str) -> ConfigResult<BackboneConfig> {
65        let base_path = base_path.as_ref();
66
67        // Load base config
68        let mut config = Self::load_file(base_path)?;
69
70        // Build environment-specific path
71        let env_path = Self::env_specific_path(base_path, env);
72
73        // Merge if exists
74        if env_path.exists() {
75            let env_config = Self::load_file(&env_path)?;
76            config = config.merge(env_config);
77        }
78
79        // Validate final config
80        config.validate()?;
81
82        Ok(config)
83    }
84
85    /// Build environment-specific path
86    ///
87    /// `config/application.yml` + `production` → `config/application-production.yml`
88    fn env_specific_path(base_path: &Path, env: &str) -> std::path::PathBuf {
89        let stem = base_path
90            .file_stem()
91            .and_then(|s| s.to_str())
92            .unwrap_or("config");
93
94        let extension = base_path
95            .extension()
96            .and_then(|e| e.to_str())
97            .unwrap_or("yml");
98
99        let parent = base_path.parent().unwrap_or(Path::new("."));
100
101        parent.join(format!("{}-{}.{}", stem, env, extension))
102    }
103
104    /// Substitute environment variables in configuration content
105    ///
106    /// Supports two formats:
107    /// - `${VAR}` - Required variable, fails if not set
108    /// - `${VAR:default}` - Optional variable with default value
109    ///
110    /// # Example
111    ///
112    /// ```ignore
113    /// let content = "url: ${DATABASE_URL:postgresql://localhost/db}";
114    /// let result = ConfigLoader::substitute_env_vars(content)?;
115    /// ```
116    pub fn substitute_env_vars(content: &str) -> ConfigResult<String> {
117        let mut result = content.to_string();
118        let mut start = 0;
119
120        while let Some(var_start) = result[start..].find("${") {
121            let abs_start = start + var_start;
122
123            let var_end = match result[abs_start..].find('}') {
124                Some(pos) => abs_start + pos,
125                None => {
126                    start = abs_start + 2;
127                    continue;
128                }
129            };
130
131            let var_content = &result[abs_start + 2..var_end];
132            let (var_name, default_value) = Self::parse_var_content(var_content);
133
134            let value = match std::env::var(var_name) {
135                Ok(val) => val,
136                Err(_) => {
137                    match default_value {
138                        Some(default) => default.to_string(),
139                        None => {
140                            // Variable not set and no default - keep original for now
141                            // This allows validation to catch missing required vars
142                            start = var_end + 1;
143                            continue;
144                        }
145                    }
146                }
147            };
148
149            result.replace_range(abs_start..=var_end, &value);
150            start = abs_start + value.len();
151        }
152
153        Ok(result)
154    }
155
156    /// Parse variable content to extract name and optional default
157    ///
158    /// `VAR` → ("VAR", None)
159    /// `VAR:default` → ("VAR", Some("default"))
160    /// `VAR:-default` → ("VAR", Some("default"))  # Bash-style
161    fn parse_var_content(content: &str) -> (&str, Option<&str>) {
162        // Handle bash-style ${VAR:-default}
163        if let Some(pos) = content.find(":-") {
164            return (&content[..pos], Some(&content[pos + 2..]));
165        }
166
167        // Handle simple ${VAR:default}
168        if let Some(pos) = content.find(':') {
169            return (&content[..pos], Some(&content[pos + 1..]));
170        }
171
172        (content, None)
173    }
174
175    /// Load configuration from a string
176    ///
177    /// Useful for testing or embedded configs.
178    pub fn from_yaml_str(content: &str) -> ConfigResult<BackboneConfig> {
179        let content = Self::substitute_env_vars(content)?;
180        Ok(serde_yaml::from_str(&content)?)
181    }
182
183    /// Load configuration from a string (TOML format)
184    pub fn from_toml_str(content: &str) -> ConfigResult<BackboneConfig> {
185        let content = Self::substitute_env_vars(content)?;
186        Ok(toml::from_str(&content)?)
187    }
188
189    /// Load configuration from a string (JSON format)
190    pub fn from_json_str(content: &str) -> ConfigResult<BackboneConfig> {
191        let content = Self::substitute_env_vars(content)?;
192        Ok(serde_json::from_str(&content)?)
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn test_substitute_env_vars_with_default() {
202        // Remove any existing HOST env var for this test
203        std::env::remove_var("HOST");
204        let content = "host: ${HOST:localhost}";
205        let result = ConfigLoader::substitute_env_vars(content).unwrap();
206        assert_eq!(result, "host: localhost");
207    }
208
209    #[test]
210    fn test_substitute_env_vars_with_env() {
211        std::env::set_var("TEST_CONFIG_VAR", "test_value");
212        let content = "value: ${TEST_CONFIG_VAR:default}";
213        let result = ConfigLoader::substitute_env_vars(content).unwrap();
214        assert_eq!(result, "value: test_value");
215        std::env::remove_var("TEST_CONFIG_VAR");
216    }
217
218    #[test]
219    fn test_substitute_env_vars_bash_style() {
220        let content = "host: ${UNDEFINED_VAR:-fallback}";
221        let result = ConfigLoader::substitute_env_vars(content).unwrap();
222        assert_eq!(result, "host: fallback");
223    }
224
225    #[test]
226    fn test_substitute_multiple_vars() {
227        let content = "url: postgresql://${DB_USER:root}:${DB_PASS:password}@${DB_HOST:localhost}:${DB_PORT:5432}";
228        let result = ConfigLoader::substitute_env_vars(content).unwrap();
229        assert_eq!(result, "url: postgresql://root:password@localhost:5432");
230    }
231
232    #[test]
233    fn test_parse_var_content() {
234        assert_eq!(ConfigLoader::parse_var_content("VAR"), ("VAR", None));
235        assert_eq!(ConfigLoader::parse_var_content("VAR:default"), ("VAR", Some("default")));
236        assert_eq!(ConfigLoader::parse_var_content("VAR:-default"), ("VAR", Some("default")));
237    }
238
239    #[test]
240    fn test_env_specific_path() {
241        let base = Path::new("config/application.yml");
242        let env_path = ConfigLoader::env_specific_path(base, "production");
243        assert_eq!(env_path.to_str().unwrap(), "config/application-production.yml");
244
245        let base = Path::new("app.toml");
246        let env_path = ConfigLoader::env_specific_path(base, "dev");
247        // On some platforms parent of "app.toml" returns "." prefix
248        let result = env_path.to_str().unwrap();
249        assert!(result == "app-dev.toml" || result == "./app-dev.toml");
250    }
251
252    #[test]
253    fn test_from_yaml_str() {
254        let yaml = r#"
255app:
256  name: "Test App"
257  version: "1.0.0"
258  debug: true
259  environment: development
260server:
261  host: "0.0.0.0"
262  port: 3000
263modules:
264  sapiens:
265    enabled: true
266    bounded_context: "user-management"
267    domain_version: "1.0.0"
268  postman:
269    enabled: false
270    bounded_context: "email-notification"
271    domain_version: "1.0.0"
272  bucket:
273    enabled: false
274    bounded_context: "file-storage"
275    domain_version: "1.0.0"
276logging:
277  level: "info"
278  structured: true
279  format: "json"
280  targets:
281    - "console"
282monitoring:
283  enabled: true
284  metrics_enabled: true
285  tracing_enabled: true
286  health_check_enabled: true
287contexts:
288  event_bus: "in_memory"
289  authentication: "sapiens"
290  file_storage: "bucket"
291features:
292  user_registration: true
293  email_verification: true
294  password_reset: true
295  two_factor_auth: false
296  social_login: false
297  audit_logging: true
298  rate_limiting: true
299security:
300  cors_enabled: true
301  cors_origins:
302    - "http://localhost:3000"
303  cors_methods:
304    - "GET"
305    - "POST"
306  cors_headers:
307    - "Content-Type"
308"#;
309
310        let config = ConfigLoader::from_yaml_str(yaml).unwrap();
311        assert_eq!(config.app.name, "Test App");
312        assert_eq!(config.server.port, 3000);
313        assert!(config.modules.sapiens.enabled);
314    }
315}