1use std::marker::PhantomData;
15use std::path::{Path, PathBuf};
16
17use figment::providers::{Env, Format, Serialized, Toml};
18use figment::Figment;
19use garde::Validate;
20use serde::de::DeserializeOwned;
21use serde_json::{Map, Value};
22
23use crate::error::{Error, Result};
24
25const REDACTED: &str = "********";
27const DEFAULT_ENVIRONMENT: &str = "development";
29const NESTING_SEPARATOR: &str = "__";
31const ENV_PLACEHOLDER: &str = "{env}";
33
34#[derive(Clone, serde::Deserialize)]
41pub struct SecretString(String);
42
43impl SecretString {
44 pub fn new(value: impl Into<String>) -> Self {
46 Self(value.into())
47 }
48
49 pub fn expose(&self) -> &str {
52 &self.0
53 }
54}
55
56impl std::fmt::Debug for SecretString {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 f.debug_tuple("SecretString").field(&REDACTED).finish()
59 }
60}
61
62impl std::fmt::Display for SecretString {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 f.write_str(REDACTED)
65 }
66}
67
68impl From<String> for SecretString {
69 fn from(value: String) -> Self {
70 Self(value)
71 }
72}
73
74impl From<&str> for SecretString {
75 fn from(value: &str) -> Self {
76 Self(value.to_owned())
77 }
78}
79
80pub struct SettingsLoader<T> {
86 env_file: Option<PathBuf>,
87 prefix: Option<String>,
88 config_file: Option<String>,
89 files: Vec<String>,
90 secrets_dir: Option<PathBuf>,
91 overrides: Map<String, Value>,
92 _marker: PhantomData<fn() -> T>,
93}
94
95impl<T> Default for SettingsLoader<T> {
96 fn default() -> Self {
97 Self::new()
98 }
99}
100
101impl<T> SettingsLoader<T> {
102 pub fn new() -> Self {
104 Self {
105 env_file: None,
106 prefix: None,
107 config_file: None,
108 files: Vec::new(),
109 secrets_dir: None,
110 overrides: Map::new(),
111 _marker: PhantomData,
112 }
113 }
114
115 pub fn env_file(mut self, path: impl Into<PathBuf>) -> Self {
118 self.env_file = Some(path.into());
119 self
120 }
121
122 pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
125 self.prefix = Some(prefix.into());
126 self
127 }
128
129 pub fn config_file(mut self, path: impl Into<String>) -> Self {
131 self.config_file = Some(path.into());
132 self
133 }
134
135 pub fn file(mut self, path: impl Into<String>) -> Self {
138 self.files.push(path.into());
139 self
140 }
141
142 pub fn secrets_dir(mut self, dir: impl Into<PathBuf>) -> Self {
145 self.secrets_dir = Some(dir.into());
146 self
147 }
148
149 pub fn override_value(mut self, key: impl AsRef<str>, value: impl serde::Serialize) -> Self {
152 if let Ok(value) = serde_json::to_value(value) {
153 let parts: Vec<&str> = key.as_ref().split('.').collect();
154 insert_nested(&mut self.overrides, &parts, value);
155 }
156 self
157 }
158
159 fn environment_name(&self) -> String {
162 let var = match &self.prefix {
163 Some(prefix) => format!("{prefix}_ENV"),
164 None => "ENV".to_owned(),
165 };
166 std::env::var(&var).unwrap_or_else(|_| DEFAULT_ENVIRONMENT.to_owned())
167 }
168
169 fn env_provider(&self) -> Env {
171 match &self.prefix {
172 Some(prefix) => Env::prefixed(&format!("{prefix}_")).split(NESTING_SEPARATOR),
173 None => Env::raw().split(NESTING_SEPARATOR),
174 }
175 }
176}
177
178impl<T: DeserializeOwned + Validate<Context = ()>> SettingsLoader<T> {
179 pub fn load(self) -> Result<T> {
185 let _env_guard = crate::env::env_guard();
192 self.load_locked()
193 }
194
195 fn load_locked(self) -> Result<T> {
202 match &self.env_file {
205 Some(path) => {
206 let _ = dotenvy::from_path(path);
207 }
208 None => {
209 let _ = dotenvy::dotenv();
210 }
211 }
212
213 let environment = self.environment_name();
214
215 let mut figment = Figment::new();
216 if let Some(config_file) = &self.config_file {
217 figment = figment.merge(Toml::file(config_file));
218 }
219 for file in &self.files {
220 let resolved = file.replace(ENV_PLACEHOLDER, &environment);
221 figment = figment.merge(Toml::file(resolved));
222 }
223 figment = figment.merge(self.env_provider());
224 if let Some(dir) = &self.secrets_dir {
225 let secrets = read_secrets(dir);
226 if !secrets.is_empty() {
227 figment = figment.merge(Serialized::defaults(Value::Object(secrets)));
228 }
229 }
230 if !self.overrides.is_empty() {
231 figment = figment.merge(Serialized::defaults(Value::Object(self.overrides.clone())));
232 }
233
234 let value: T = figment.extract().map_err(|error| {
235 let message = error.to_string();
236 Error::internal(format!("failed to load configuration: {message}"))
237 .with_code("CONFIG_LOAD_FAILED")
238 .with_source(error)
239 })?;
240
241 value.validate().map_err(|report| {
242 Error::from_garde_report(report).with_code("CONFIG_VALIDATION_ERROR")
243 })?;
244
245 Ok(value)
246 }
247}
248
249fn read_secrets(dir: &Path) -> Map<String, Value> {
251 let mut root = Map::new();
252 let Ok(entries) = std::fs::read_dir(dir) else {
253 return root;
254 };
255 for entry in entries.flatten() {
256 let path = entry.path();
257 if !path.is_file() {
258 continue;
259 }
260 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
261 continue;
262 };
263 let Ok(contents) = std::fs::read_to_string(&path) else {
264 continue;
265 };
266 let key = name.to_lowercase();
267 let parts: Vec<&str> = key.split(NESTING_SEPARATOR).collect();
268 insert_nested(&mut root, &parts, Value::String(contents.trim().to_owned()));
269 }
270 root
271}
272
273fn insert_nested(root: &mut Map<String, Value>, parts: &[&str], value: Value) {
275 match parts {
276 [] => {}
277 [key] => {
278 root.insert((*key).to_owned(), value);
279 }
280 [key, rest @ ..] => {
281 let entry = root
282 .entry((*key).to_owned())
283 .or_insert_with(|| Value::Object(Map::new()));
284 if !entry.is_object() {
285 *entry = Value::Object(Map::new());
286 }
287 if let Value::Object(map) = entry {
288 insert_nested(map, rest, value);
289 }
290 }
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297 use crate::env::env_guard;
298 use garde::Validate;
299 use serde::Deserialize;
300 use std::io::Write;
301
302 #[derive(Debug, Deserialize, Validate)]
303 struct Nested {
304 #[garde(skip)]
305 host: String,
306 #[garde(range(min = 1, max = 65535))]
307 port: u16,
308 }
309
310 #[derive(Debug, Deserialize, Validate)]
311 struct Sample {
312 #[serde(default = "default_name")]
313 #[garde(skip)]
314 name: String,
315 #[garde(range(min = 1, max = 500))]
316 items: u32,
317 #[garde(dive)]
318 nested: Nested,
319 #[garde(skip)]
320 token: SecretString,
321 }
322
323 fn default_name() -> String {
324 "Awesome API".to_owned()
325 }
326
327 #[test]
328 fn builder_methods_store_configuration_sources() {
329 let loader = SettingsLoader::<Sample>::default()
330 .env_file("config/.env.test")
331 .prefix("CFGTESTZ")
332 .config_file("config/base.toml")
333 .file("config/{env}.toml")
334 .secrets_dir("secrets")
335 .override_value("nested.port", 7000u16);
336
337 assert_eq!(
338 loader.env_file.as_deref(),
339 Some(Path::new("config/.env.test"))
340 );
341 assert_eq!(loader.prefix.as_deref(), Some("CFGTESTZ"));
342 assert_eq!(loader.config_file.as_deref(), Some("config/base.toml"));
343 assert_eq!(loader.files, vec!["config/{env}.toml"]);
344 assert_eq!(loader.secrets_dir.as_deref(), Some(Path::new("secrets")));
345 assert_eq!(loader.overrides["nested"]["port"], Value::from(7000u16));
346 }
347
348 #[test]
349 fn environment_name_uses_prefix_and_default() {
350 let _guard = env_guard();
351 std::env::remove_var("ENV");
352 std::env::remove_var("CFGTESTENV_ENV");
353 assert_eq!(
354 SettingsLoader::<Sample>::new().environment_name(),
355 "development"
356 );
357
358 std::env::set_var("ENV", "staging");
359 std::env::set_var("CFGTESTENV_ENV", "production");
360 assert_eq!(
361 SettingsLoader::<Sample>::new().environment_name(),
362 "staging"
363 );
364 assert_eq!(
365 SettingsLoader::<Sample>::new()
366 .prefix("CFGTESTENV")
367 .environment_name(),
368 "production"
369 );
370
371 std::env::remove_var("ENV");
372 std::env::remove_var("CFGTESTENV_ENV");
373 }
374
375 #[test]
376 fn read_secrets_and_insert_nested_cover_edge_cases() {
377 let dir = tempfile::tempdir().unwrap();
378 std::fs::create_dir(dir.path().join("nested")).unwrap();
379 std::fs::write(dir.path().join("TOKEN"), " shh \n").unwrap();
380 std::fs::write(dir.path().join("DB__PORT"), "5432").unwrap();
381
382 let secrets = read_secrets(dir.path());
383 assert_eq!(secrets["token"], "shh");
384 assert_eq!(secrets["db"]["port"], "5432");
385 assert!(read_secrets(&dir.path().join("missing")).is_empty());
386
387 let mut root = Map::new();
388 insert_nested(&mut root, &[], Value::from("ignored"));
389 assert!(root.is_empty());
390
391 root.insert("db".to_owned(), Value::from("scalar"));
392 insert_nested(&mut root, &["db", "host"], Value::from("localhost"));
393 assert_eq!(root["db"]["host"], "localhost");
394 }
395
396 #[test]
397 fn secret_string_is_masked_but_exposable() {
398 let secret = SecretString::new("super-secret");
399 assert_eq!(format!("{secret:?}"), "SecretString(\"********\")");
400 assert_eq!(format!("{secret}"), "********");
401 assert_eq!(secret.expose(), "super-secret");
402 }
403
404 #[test]
405 fn defaults_apply_and_overrides_win() {
406 let value: Sample = SettingsLoader::new()
408 .prefix("CFGTESTA")
409 .override_value("items", 42u32)
410 .override_value("nested.host", "localhost")
411 .override_value("nested.port", 8080u16)
412 .override_value("token", "shh")
413 .load()
414 .expect("load should succeed");
415
416 assert_eq!(value.name, "Awesome API"); assert_eq!(value.items, 42);
418 assert_eq!(value.nested.host, "localhost");
419 assert_eq!(value.nested.port, 8080);
420 assert_eq!(value.token.expose(), "shh");
421 }
422
423 #[test]
424 fn environment_variable_overrides_and_nests() {
425 let _guard = env_guard();
426 std::env::set_var("CFGTESTB_NAME", "From Env");
428 std::env::set_var("CFGTESTB_ITEMS", "7");
429 std::env::set_var("CFGTESTB_NESTED__HOST", "db.internal");
430 std::env::set_var("CFGTESTB_NESTED__PORT", "5432");
431 std::env::set_var("CFGTESTB_TOKEN", "envtoken");
432
433 let value: Sample = SettingsLoader::new()
434 .prefix("CFGTESTB")
435 .load_locked()
437 .expect("load should succeed");
438
439 assert_eq!(value.name, "From Env");
440 assert_eq!(value.items, 7);
441 assert_eq!(value.nested.host, "db.internal");
442 assert_eq!(value.nested.port, 5432);
443 assert_eq!(value.token.expose(), "envtoken");
444
445 for key in ["NAME", "ITEMS", "NESTED__HOST", "NESTED__PORT", "TOKEN"] {
446 std::env::remove_var(format!("CFGTESTB_{key}"));
447 }
448 }
449
450 #[test]
451 fn environment_variable_overrides_a_config_file() {
452 let _guard = env_guard();
453 let dir = tempfile::tempdir().unwrap();
454 let path = dir.path().join("config.toml");
455 let mut file = std::fs::File::create(&path).unwrap();
456 writeln!(
457 file,
458 "name = \"From File\"\nitems = 3\ntoken = \"filetoken\"\n\n[nested]\nhost = \"file.host\"\nport = 1111"
459 )
460 .unwrap();
461
462 std::env::set_var("CFGTESTD_ITEMS", "9");
464
465 let value: Sample = SettingsLoader::new()
466 .prefix("CFGTESTD")
467 .config_file(path.to_string_lossy().into_owned())
468 .load_locked()
470 .expect("load should succeed");
471
472 assert_eq!(value.name, "From File"); assert_eq!(value.items, 9); assert_eq!(value.nested.host, "file.host");
475 assert_eq!(value.nested.port, 1111);
476
477 std::env::remove_var("CFGTESTD_ITEMS");
478 }
479
480 #[test]
481 fn validation_failure_is_reported() {
482 let error = SettingsLoader::<Sample>::new()
483 .prefix("CFGTESTC")
484 .override_value("items", 9999u32) .override_value("nested.host", "localhost")
486 .override_value("nested.port", 80u16)
487 .override_value("token", "shh")
488 .load()
489 .unwrap_err();
490
491 assert_eq!(error.code(), "CONFIG_VALIDATION_ERROR");
492 }
493
494 #[test]
495 fn load_merges_env_file_environment_specific_file_and_secrets() {
496 let _guard = env_guard();
497 let dir = tempfile::tempdir().unwrap();
498 let base = dir.path().join("base.toml");
499 let env_file = dir.path().join(".env");
500 let env_toml = dir.path().join("production.toml");
501 let secrets = dir.path().join("secrets");
502 std::fs::create_dir(&secrets).unwrap();
503
504 let mut base_file = std::fs::File::create(&base).unwrap();
505 writeln!(
506 base_file,
507 "name = \"Base\"\nitems = 3\ntoken = \"from-base\"\n\n[nested]\nhost = \"file.host\"\nport = 1111"
508 )
509 .unwrap();
510 std::fs::write(&env_file, "CFGTESTE_ENV=production\nCFGTESTE_ITEMS=9\n").unwrap();
511 let mut env_override = std::fs::File::create(&env_toml).unwrap();
512 writeln!(
513 env_override,
514 "name = \"Prod\"\n[nested]\nhost = \"prod.host\""
515 )
516 .unwrap();
517 std::fs::write(secrets.join("TOKEN"), "from-secret\n").unwrap();
518
519 let value: Sample = SettingsLoader::new()
520 .env_file(&env_file)
521 .prefix("CFGTESTE")
522 .config_file(base.to_string_lossy().into_owned())
523 .file(dir.path().join("{env}.toml").to_string_lossy().into_owned())
524 .secrets_dir(&secrets)
525 .load_locked()
527 .expect("load should succeed");
528
529 assert_eq!(value.name, "Prod");
530 assert_eq!(value.items, 9);
531 assert_eq!(value.nested.host, "prod.host");
532 assert_eq!(value.nested.port, 1111);
533 assert_eq!(value.token.expose(), "from-secret");
534
535 std::env::remove_var("CFGTESTE_ENV");
536 std::env::remove_var("CFGTESTE_ITEMS");
537 }
538
539 #[test]
540 fn load_reports_configuration_parse_failures() {
541 let dir = tempfile::tempdir().unwrap();
542 let path = dir.path().join("broken.toml");
543 std::fs::write(
544 &path,
545 "items = \"oops\"\ntoken = \"x\"\n[nested]\nhost = \"h\"\nport = 1",
546 )
547 .unwrap();
548
549 let error = SettingsLoader::<Sample>::new()
550 .config_file(path.to_string_lossy().into_owned())
551 .load()
552 .unwrap_err();
553
554 assert_eq!(error.code(), "CONFIG_LOAD_FAILED");
555 assert!(error.message().starts_with("failed to load configuration:"));
556 }
557}