//! Application configuration settings. //! //! This module provides configuration structures that can be loaded from: //! - YAML configuration files (base.yaml and environment-specific files) //! - Environment variables (prefixed with APP__) //! //! Settings include application details, Modbus connection parameters, relay configuration, //! rate limiting, and environment settings. /// Application configuration settings. /// /// Loads configuration from YAML files and environment variables. #[derive(Debug, serde::Deserialize, Clone, Default)] pub struct Settings { /// Application-specific settings (name, version, host, port, etc.) pub application: ApplicationSettings, /// Debug mode flag pub debug: bool, /// Frontend URL for CORS configuration pub frontend_url: String, /// Rate limiting configuration #[serde(default)] pub rate_limit: RateLimitSettings, /// Modbus configuration pub modbus: ModbusSettings, /// Relay configuration pub relay: RelaySettings, } impl Settings { /// Creates a new `Settings` instance by loading configuration from files and environment variables. /// /// # Errors /// /// Returns a `config::ConfigError` if: /// - Configuration files cannot be read or parsed /// - Required configuration values are missing /// - Configuration values cannot be deserialized into the expected types /// /// # Panics /// /// Panics if: /// - The current directory cannot be determined /// - The `APP_ENVIRONMENT` variable contains an invalid value (not "dev", "development", "prod", or "production") pub fn new() -> Result { // Use CARGO_MANIFEST_DIR to reliably locate settings regardless of where cargo is run from let base_path = std::env::var("CARGO_MANIFEST_DIR").map_or_else( // Fallback to current_dir for non-cargo builds |_| std::env::current_dir().expect("Failed to determine the current directory"), std::path::PathBuf::from, ); println!("Reading settings from directory {}", base_path.display()); let settings_directory = base_path.join("settings"); let environment: Environment = std::env::var("APP_ENVIRONMENT") .unwrap_or_else(|_| "dev".into()) .try_into() .expect("Failed to parse APP_ENVIRONMENT"); let environment_filename = format!("{environment}.yaml"); // Lower = takes precedence let settings = config::Config::builder() .add_source(config::File::from(settings_directory.join("base.yaml"))) .add_source(config::File::from( settings_directory.join(environment_filename), )) .add_source( config::Environment::with_prefix("APP") .prefix_separator("__") .separator("__"), ) .build()?; settings.try_deserialize() } } /// Application-specific configuration settings. #[derive(Debug, serde::Deserialize, Clone, Default)] pub struct ApplicationSettings { /// Application name pub name: String, /// Application version pub version: String, /// Port to bind to pub port: u16, /// Host address to bind to pub host: String, /// Base URL of the application pub base_url: String, /// Protocol (http or https) pub protocol: String, } /// Application environment. #[derive(Debug, PartialEq, Eq, Default)] pub enum Environment { /// Development environment #[default] Development, /// Production environment Production, } impl std::fmt::Display for Environment { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let self_str = match self { Self::Development => "development", Self::Production => "production", }; write!(f, "{self_str}") } } impl TryFrom for Environment { type Error = String; fn try_from(value: String) -> Result { Self::try_from(value.as_str()) } } impl TryFrom<&str> for Environment { type Error = String; fn try_from(value: &str) -> Result { match value.to_lowercase().as_str() { "development" | "dev" => Ok(Self::Development), "production" | "prod" => Ok(Self::Production), other => Err(format!( "{other} is not a supported environment. Use either `development` or `production`" )), } } } /// Rate limiting configuration. #[derive(Debug, serde::Deserialize, Clone)] pub struct RateLimitSettings { /// Whether rate limiting is enabled #[serde(default = "default_rate_limit_enabled")] pub enabled: bool, /// Maximum number of requests allowed in the time window (burst size) #[serde(default = "default_burst_size")] pub burst_size: u32, /// Time window in seconds for rate limiting #[serde(default = "default_per_seconds")] pub per_seconds: u64, } impl Default for RateLimitSettings { fn default() -> Self { Self { enabled: default_rate_limit_enabled(), burst_size: default_burst_size(), per_seconds: default_per_seconds(), } } } const fn default_rate_limit_enabled() -> bool { true } const fn default_burst_size() -> u32 { 100 } const fn default_per_seconds() -> u64 { 60 } /// Modbus TCP connection configuration. /// /// Configures the connection parameters for communicating with the Modbus relay device /// using Modbus RTU over TCP protocol. #[derive(Debug, serde::Deserialize, Clone)] pub struct ModbusSettings { /// IP address or hostname of the Modbus device pub host: String, /// TCP port for Modbus communication (standard Modbus TCP port is 502) pub port: u16, /// Modbus slave/device ID (unit identifier) pub slave_id: u8, /// Operation timeout in seconds pub timeout_secs: u8, } impl Default for ModbusSettings { fn default() -> Self { Self { host: "192.168.0.200".to_string(), port: 502, slave_id: 0, timeout_secs: 5, } } } /// Relay control configuration. /// /// Configures parameters for relay management and labeling. #[derive(Debug, serde::Deserialize, Clone)] pub struct RelaySettings { /// Maximum length for custom relay labels (in characters) pub label_max_length: u8, } impl Default for RelaySettings { fn default() -> Self { Self { label_max_length: 8, } } } #[cfg(test)] mod tests { use super::*; #[test] fn environment_display_development() { let env = Environment::Development; assert_eq!(env.to_string(), "development"); } #[test] fn environment_display_production() { let env = Environment::Production; assert_eq!(env.to_string(), "production"); } #[test] fn environment_from_str_development() { assert_eq!( Environment::try_from("development").unwrap(), Environment::Development ); assert_eq!( Environment::try_from("dev").unwrap(), Environment::Development ); assert_eq!( Environment::try_from("Development").unwrap(), Environment::Development ); assert_eq!( Environment::try_from("DEV").unwrap(), Environment::Development ); } #[test] fn environment_from_str_production() { assert_eq!( Environment::try_from("production").unwrap(), Environment::Production ); assert_eq!( Environment::try_from("prod").unwrap(), Environment::Production ); assert_eq!( Environment::try_from("Production").unwrap(), Environment::Production ); assert_eq!( Environment::try_from("PROD").unwrap(), Environment::Production ); } #[test] fn environment_from_str_invalid() { let result = Environment::try_from("invalid"); assert!(result.is_err()); assert!(result.unwrap_err().contains("not a supported environment")); } #[test] fn environment_from_string_development() { assert_eq!( Environment::try_from("development".to_string()).unwrap(), Environment::Development ); } #[test] fn environment_from_string_production() { assert_eq!( Environment::try_from("production".to_string()).unwrap(), Environment::Production ); } #[test] fn environment_from_string_invalid() { let result = Environment::try_from("invalid".to_string()); assert!(result.is_err()); } #[test] fn environment_default_is_development() { let env = Environment::default(); assert_eq!(env, Environment::Development); } #[test] fn rate_limit_settings_default() { let settings = RateLimitSettings::default(); assert!(settings.enabled); assert_eq!(settings.burst_size, 100); assert_eq!(settings.per_seconds, 60); } #[test] fn rate_limit_settings_deserialize_full() { let json = r#"{"enabled": true, "burst_size": 50, "per_seconds": 30}"#; let settings: RateLimitSettings = serde_json::from_str(json).unwrap(); assert!(settings.enabled); assert_eq!(settings.burst_size, 50); assert_eq!(settings.per_seconds, 30); } #[test] fn rate_limit_settings_deserialize_partial() { let json = r#"{"enabled": false}"#; let settings: RateLimitSettings = serde_json::from_str(json).unwrap(); assert!(!settings.enabled); assert_eq!(settings.burst_size, 100); // default assert_eq!(settings.per_seconds, 60); // default } #[test] fn rate_limit_settings_deserialize_empty() { let json = "{}"; let settings: RateLimitSettings = serde_json::from_str(json).unwrap(); assert!(settings.enabled); // default assert_eq!(settings.burst_size, 100); // default assert_eq!(settings.per_seconds, 60); // default } }