Skip to main content

tork_core/
settings.rs

1//! Typed application configuration.
2//!
3//! [`SettingsLoader`] merges configuration from several sources into one typed,
4//! validated value, loaded once at startup. The sources, from lowest to highest
5//! precedence, are: struct defaults, a base config file, environment-specific
6//! files, a `.env` file, environment variables, a secrets directory, and explicit
7//! overrides. [`SecretString`] holds sensitive values without exposing them in
8//! logs.
9//!
10//! The `#[settings]` macro generates a struct that derives `Deserialize` and
11//! `garde::Validate` and a `load()` method built on this loader, but the loader is
12//! usable directly with any `DeserializeOwned + garde::Validate` type.
13
14use 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
25/// Placeholder rendered as a masked secret value.
26const REDACTED: &str = "********";
27/// Default environment name when none is set.
28const DEFAULT_ENVIRONMENT: &str = "development";
29/// Separator marking a nesting level in an environment variable or secret name.
30const NESTING_SEPARATOR: &str = "__";
31/// Token replaced with the resolved environment name in a file path.
32const ENV_PLACEHOLDER: &str = "{env}";
33
34/// A string whose value is kept out of logs and debug output.
35///
36/// `Debug` and `Display` render a fixed mask; the value is only readable through
37/// [`SecretString::expose`]. It deserializes from a plain string, so it can stand
38/// in for any secret configuration field. It is intentionally not `Serialize`, so
39/// a configuration carrying secrets cannot be written back out by accident.
40#[derive(Clone, serde::Deserialize)]
41pub struct SecretString(String);
42
43impl SecretString {
44    /// Wraps a value as a secret.
45    pub fn new(value: impl Into<String>) -> Self {
46        Self(value.into())
47    }
48
49    /// Returns the underlying value. This is the only way to read a secret, so
50    /// call sites that expose it are easy to audit.
51    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
80/// Loads and validates a typed configuration from layered sources.
81///
82/// Build it with the source methods, then call [`SettingsLoader::load`]. Each
83/// source overrides the ones before it; see the module documentation for the full
84/// precedence order.
85pub 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    /// Creates a loader with no sources configured.
103    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    /// Loads variables from this `.env` file before reading the environment.
116    /// Existing environment variables are not overwritten.
117    pub fn env_file(mut self, path: impl Into<PathBuf>) -> Self {
118        self.env_file = Some(path.into());
119        self
120    }
121
122    /// Reads environment variables that start with `prefix` followed by `_`.
123    /// Nested fields use `__` between levels (for example `APP_SERVER__PORT`).
124    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
125        self.prefix = Some(prefix.into());
126        self
127    }
128
129    /// Reads a base configuration file (TOML). A missing file is ignored.
130    pub fn config_file(mut self, path: impl Into<String>) -> Self {
131        self.config_file = Some(path.into());
132        self
133    }
134
135    /// Appends an environment-specific file (TOML). The `{env}` token in the path
136    /// is replaced with the resolved environment name. A missing file is ignored.
137    pub fn file(mut self, path: impl Into<String>) -> Self {
138        self.files.push(path.into());
139        self
140    }
141
142    /// Reads one secret per file from a directory: the file name is the key (with
143    /// `__` marking nesting) and the trimmed contents are the value.
144    pub fn secrets_dir(mut self, dir: impl Into<PathBuf>) -> Self {
145        self.secrets_dir = Some(dir.into());
146        self
147    }
148
149    /// Sets the highest-priority override for a key, overriding every source.
150    /// The key uses `.` between nesting levels (for example `server.port`).
151    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    /// Resolves the current environment name from `{PREFIX}_ENV` (or `ENV` when no
160    /// prefix is set), defaulting to `development`.
161    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    /// Builds the environment-variable provider with the configured prefix.
170    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    /// Merges every source, deserializes into `T`, and validates it.
180    ///
181    /// Returns an error with code `CONFIG_LOAD_FAILED` when a source cannot be
182    /// parsed into `T`, or `CONFIG_VALIDATION_ERROR` (with field details) when the
183    /// value fails validation. Used at startup, either aborts before the app runs.
184    pub fn load(self) -> Result<T> {
185        // `dotenvy` writes into the process environment via `set_var`, which races
186        // with any concurrent environment read/write from another thread. Hold the
187        // shared environment lock across the `.env` load and the environment reads
188        // (env name + the figment env provider) so concurrent `load()` calls
189        // serialize instead of racing. Mutating `std::env` from outside the loader
190        // while it runs is still the caller's responsibility to avoid.
191        let _env_guard = crate::env::env_guard();
192        self.load_locked()
193    }
194
195    /// Loads the configuration assuming the caller already holds the shared
196    /// environment lock (see [`load`](SettingsLoader::load)).
197    ///
198    /// Kept separate so callers that must hold the lock across their own
199    /// environment mutations (the tests below) do not deadlock by re-locking a
200    /// non-reentrant mutex inside `load`.
201    fn load_locked(self) -> Result<T> {
202        // Load the `.env` file into the process environment. dotenvy does not
203        // overwrite existing variables, so a real environment variable still wins.
204        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
249/// Reads a secrets directory into a nested map keyed by file name.
250fn 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
273/// Inserts `value` into `root` along a path of nested object keys.
274fn 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        // A unique prefix keeps this test independent of other tests' env vars.
407        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"); // serde default
417        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        // The prefix is unique to this test, so the variables do not collide.
427        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            // The guard is already held above, so use the non-locking variant.
436            .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        // The environment value must win over the file value.
463        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            // The guard is already held above, so use the non-locking variant.
469            .load_locked()
470            .expect("load should succeed");
471
472        assert_eq!(value.name, "From File"); // only in the file
473        assert_eq!(value.items, 9); // env overrides the file
474        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) // exceeds max of 500
485            .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            // The guard is already held above, so use the non-locking variant.
526            .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}