aro-web 1.0.0

HTTP/ADR layer for the Aro web framework
Documentation
//! Layered configuration loading for Aro applications.
//!
//! Before loading, [`dotenvy::dotenv()`] reads a `.env` file into the process
//! environment (non-fatal if missing). Config crate sources, lowest to highest:
//! 1. File: `config/{environment}.toml` (environment from `ARO_ENV`, default `"development"`)
//! 2. Environment variables with prefix `APP_` (separator `__`)

use config::{Config, Environment, File};
use serde::de::DeserializeOwned;

pub use config::ConfigError;

/// Load configuration into `T` using layered sources.
///
/// Before loading, [`dotenvy::dotenv()`] reads a `.env` file into the process
/// environment (non-fatal if missing). Config crate sources, lowest to highest:
/// 1. `config/{env}.toml` where `env` is read from `ARO_ENV` (default: `"development"`)
/// 2. Environment variables with prefix `APP_` (separator `__`)
pub fn load_config<T: DeserializeOwned>() -> Result<T, ConfigError> {
    // Load .env file if present (non-fatal if missing)
    dotenvy::dotenv().ok();

    let environment = std::env::var("ARO_ENV").unwrap_or_else(|_| "development".to_string());

    let config = Config::builder()
        .add_source(File::with_name(&format!("config/{environment}")).required(false))
        .add_source(
            Environment::with_prefix("APP")
                .prefix_separator("_")
                .separator("__"),
        )
        .build()?;

    config.try_deserialize()
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde::Deserialize;

    #[test]
    fn missing_dotenv_does_not_error() {
        #[derive(Debug, Deserialize)]
        struct Empty {}

        let result: Result<Empty, _> = load_config();
        assert!(result.is_ok());
    }

    #[test]
    fn config_returns_result_on_success() {
        use crate::App;

        #[derive(Debug, Deserialize)]
        struct Empty {}

        let app = App::new().config::<Empty>();
        assert!(app.is_ok());
    }

    #[test]
    fn config_returns_err_when_required_field_missing() {
        use crate::App;

        #[derive(Debug, Deserialize)]
        #[expect(
            dead_code,
            reason = "field exists only to force deserialization failure"
        )]
        struct RequiresField {
            /// Unique field name unlikely to be set in CI or local env.
            aro_config_test_required_secret: String,
        }

        let result = App::new().config::<RequiresField>();
        assert!(result.is_err());
    }

    #[test]
    fn config_available_via_dep() {
        use crate::dep::Dep;
        use crate::state::AroState;
        use axum::extract::FromRef;
        use std::sync::Arc;

        #[derive(Debug, Deserialize, PartialEq)]
        struct MyConfig {
            name: String,
        }

        let state = AroState::builder()
            .register::<MyConfig>(Arc::new(MyConfig {
                name: "test".to_string(),
            }))
            .build();

        let dep = Dep::<MyConfig>::from_ref(&state);
        assert_eq!(dep.name, "test");
    }
}