Skip to main content

aro_web/
config.rs

1//! Layered configuration loading for Aro applications.
2//!
3//! Before loading, [`dotenvy::dotenv()`] reads a `.env` file into the process
4//! environment (non-fatal if missing). Config crate sources, lowest to highest:
5//! 1. File: `config/{environment}.toml` (environment from `ARO_ENV`, default `"development"`)
6//! 2. Environment variables with prefix `APP_` (separator `__`)
7
8use config::{Config, Environment, File};
9use serde::de::DeserializeOwned;
10
11pub use config::ConfigError;
12
13/// Load configuration into `T` using layered sources.
14///
15/// Before loading, [`dotenvy::dotenv()`] reads a `.env` file into the process
16/// environment (non-fatal if missing). Config crate sources, lowest to highest:
17/// 1. `config/{env}.toml` where `env` is read from `ARO_ENV` (default: `"development"`)
18/// 2. Environment variables with prefix `APP_` (separator `__`)
19pub fn load_config<T: DeserializeOwned>() -> Result<T, ConfigError> {
20    // Load .env file if present (non-fatal if missing)
21    dotenvy::dotenv().ok();
22
23    let environment = std::env::var("ARO_ENV").unwrap_or_else(|_| "development".to_string());
24
25    let config = Config::builder()
26        .add_source(File::with_name(&format!("config/{environment}")).required(false))
27        .add_source(
28            Environment::with_prefix("APP")
29                .prefix_separator("_")
30                .separator("__"),
31        )
32        .build()?;
33
34    config.try_deserialize()
35}
36
37#[cfg(test)]
38mod tests {
39    use super::*;
40    use serde::Deserialize;
41
42    #[test]
43    fn missing_dotenv_does_not_error() {
44        #[derive(Debug, Deserialize)]
45        struct Empty {}
46
47        let result: Result<Empty, _> = load_config();
48        assert!(result.is_ok());
49    }
50
51    #[test]
52    fn config_returns_result_on_success() {
53        use crate::App;
54
55        #[derive(Debug, Deserialize)]
56        struct Empty {}
57
58        let app = App::new().config::<Empty>();
59        assert!(app.is_ok());
60    }
61
62    #[test]
63    fn config_returns_err_when_required_field_missing() {
64        use crate::App;
65
66        #[derive(Debug, Deserialize)]
67        #[expect(
68            dead_code,
69            reason = "field exists only to force deserialization failure"
70        )]
71        struct RequiresField {
72            /// Unique field name unlikely to be set in CI or local env.
73            aro_config_test_required_secret: String,
74        }
75
76        let result = App::new().config::<RequiresField>();
77        assert!(result.is_err());
78    }
79
80    #[test]
81    fn config_available_via_dep() {
82        use crate::dep::Dep;
83        use crate::state::AroState;
84        use axum::extract::FromRef;
85        use std::sync::Arc;
86
87        #[derive(Debug, Deserialize, PartialEq)]
88        struct MyConfig {
89            name: String,
90        }
91
92        let state = AroState::builder()
93            .register::<MyConfig>(Arc::new(MyConfig {
94                name: "test".to_string(),
95            }))
96            .build();
97
98        let dep = Dep::<MyConfig>::from_ref(&state);
99        assert_eq!(dep.name, "test");
100    }
101}