backbone_core/config/
loader.rs1use super::{BackboneConfig, ConfigError, ConfigResult};
6use std::path::Path;
7
8pub struct ConfigLoader;
10
11impl ConfigLoader {
12 pub fn load_file<P: AsRef<Path>>(path: P) -> ConfigResult<BackboneConfig> {
22 let path = path.as_ref();
23
24 if !path.exists() {
26 return Err(ConfigError::file_not_found(path));
27 }
28
29 let content = std::fs::read_to_string(path)
31 .map_err(|e| ConfigError::read_error(path, e))?;
32
33 let content = Self::substitute_env_vars(&content)?;
35
36 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 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 let mut config = Self::load_file(base_path)?;
69
70 let env_path = Self::env_specific_path(base_path, env);
72
73 if env_path.exists() {
75 let env_config = Self::load_file(&env_path)?;
76 config = config.merge(env_config);
77 }
78
79 config.validate()?;
81
82 Ok(config)
83 }
84
85 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 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 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 fn parse_var_content(content: &str) -> (&str, Option<&str>) {
162 if let Some(pos) = content.find(":-") {
164 return (&content[..pos], Some(&content[pos + 2..]));
165 }
166
167 if let Some(pos) = content.find(':') {
169 return (&content[..pos], Some(&content[pos + 1..]));
170 }
171
172 (content, None)
173 }
174
175 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 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 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 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 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}