use std::fmt;
use std::path::PathBuf;
use std::time::Duration;
use crate::core::{AppError, ErrorKind};
#[derive(Serialize, Deserialize)]
pub struct Secret<T>(T);
impl<T> Secret<T> {
pub fn new(value: T) -> Self {
Self(value)
}
pub fn into_inner(self) -> T {
self.0
}
pub fn inner(&self) -> &T {
&self.0
}
}
impl<T: fmt::Debug> fmt::Debug for Secret<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[REDACTED]")
}
}
impl<T: fmt::Display> fmt::Display for Secret<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[REDACTED]")
}
}
impl<T: Clone> Clone for Secret<T> {
fn clone(&self) -> Self {
Self(self.0.clone())
}
}
impl<T> From<T> for Secret<T> {
fn from(value: T) -> Self {
Self(value)
}
}
impl<T: PartialEq> PartialEq for Secret<T> {
fn eq(&self, other: &Self) -> bool {
self.0 == other.0
}
}
impl<T: Eq> Eq for Secret<T> {}
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
#[serde(default = "default_host")]
pub host: String,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default = "default_request_timeout")]
pub request_timeout: Duration,
#[serde(default = "default_max_body_size")]
pub max_body_size: usize,
}
fn default_host() -> String {
"127.0.0.1".to_string()
}
fn default_port() -> u16 {
3000
}
fn default_request_timeout() -> Duration {
Duration::from_secs(30)
}
fn default_max_body_size() -> usize {
1024 * 1024
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: default_host(),
port: default_port(),
request_timeout: default_request_timeout(),
max_body_size: default_max_body_size(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageConfig {
#[serde(default)]
pub mode: StorageMode,
pub persistent_path: Option<PathBuf>,
pub http_url: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum StorageMode {
#[default]
Memory,
Persistent,
Http,
}
impl StorageMode {
pub fn from_str(s: &str) -> Result<Self, ConfigError> {
match s.to_lowercase().as_str() {
"memory" => Ok(Self::Memory),
"persistent" => Ok(Self::Persistent),
"http" => Ok(Self::Http),
_ => Err(ConfigError::InvalidValue {
field: "storage_mode".to_string(),
value: s.to_string(),
expected: "memory, persistent, or http".to_string(),
}),
}
}
}
impl Default for StorageConfig {
fn default() -> Self {
Self {
mode: StorageMode::Memory,
persistent_path: None,
http_url: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthConfig {
#[serde(default)]
pub enabled: bool,
pub jwt_secret: Option<Secret<String>>,
#[serde(default = "default_api_key_header")]
pub api_key_header: String,
}
fn default_api_key_header() -> String {
"X-API-Key".to_string()
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
enabled: false,
jwt_secret: None,
api_key_header: default_api_key_header(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoggingConfig {
#[serde(default = "default_log_level")]
pub level: String,
#[serde(default)]
pub format: LogFormat,
}
fn default_log_level() -> String {
"info".to_string()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LogFormat {
#[default]
Pretty,
Json,
Compact,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
level: default_log_level(),
format: LogFormat::Pretty,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
#[serde(default)]
pub server: ServerConfig,
#[serde(default)]
pub storage: StorageConfig,
#[serde(default)]
pub auth: AuthConfig,
#[serde(default)]
pub logging: LoggingConfig,
}
impl AppConfig {
pub fn from_env() -> Result<Self, ConfigError> {
let mut config = Self::default();
if let Ok(host) = std::env::var("ARQEN_HOST") {
config.server.host = host;
}
if let Ok(port) = std::env::var("ARQEN_PORT") {
config.server.port = port
.parse()
.map_err(|_| ConfigError::InvalidValue {
field: "port".to_string(),
value: port,
expected: "a valid u16".to_string(),
})?;
}
if let Ok(mode) = std::env::var("ARQEN_STORAGE_MODE") {
config.storage.mode = StorageMode::from_str(&mode)?;
}
if let Ok(path) = std::env::var("ARQEN_PERSISTENT_PATH") {
config.storage.persistent_path = Some(PathBuf::from(path));
}
if let Ok(url) = std::env::var("ARQEN_THINGD_URL") {
config.storage.http_url = Some(url);
}
if let Ok(secret) = std::env::var("ARQEN_JWT_SECRET") {
config.auth.enabled = true;
config.auth.jwt_secret = Some(Secret::new(secret));
}
if let Ok(header) = std::env::var("ARQEN_API_KEY_HEADER") {
config.auth.api_key_header = header;
}
if let Ok(level) = std::env::var("ARQEN_LOG_LEVEL") {
config.logging.level = level;
}
if let Ok(format) = std::env::var("ARQEN_LOG_FORMAT") {
config.logging.format = match format.to_lowercase().as_str() {
"pretty" => LogFormat::Pretty,
"json" => LogFormat::Json,
"compact" => LogFormat::Compact,
_ => return Err(ConfigError::InvalidValue {
field: "log_format".to_string(),
value: format,
expected: "pretty, json, or compact".to_string(),
}),
};
}
config.validate()?;
Ok(config)
}
pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self, ConfigError> {
let content = std::fs::read_to_string(path.as_ref()).map_err(|e| ConfigError::FileError {
path: path.as_ref().to_path_buf(),
source: e,
})?;
let config: Self = toml::from_str(&content).map_err(ConfigError::ParseError)?;
config.validate()?;
Ok(config)
}
pub fn validate(&self) -> Result<(), ConfigError> {
if self.server.port == 0 {
return Err(ConfigError::InvalidValue {
field: "port".to_string(),
value: "0".to_string(),
expected: "a non-zero port number".to_string(),
});
}
if self.storage.mode == StorageMode::Persistent && self.storage.persistent_path.is_none() {
return Err(ConfigError::MissingField {
field: "persistent_path".to_string(),
context: "required when storage mode is persistent".to_string(),
});
}
if self.storage.mode == StorageMode::Http && self.storage.http_url.is_none() {
return Err(ConfigError::MissingField {
field: "http_url".to_string(),
context: "required when storage mode is http".to_string(),
});
}
Ok(())
}
pub fn address(&self) -> Result<std::net::SocketAddr, ConfigError> {
format!("{}:{}", self.server.host, self.server.port)
.parse()
.map_err(|_| ConfigError::InvalidValue {
field: "address".to_string(),
value: format!("{}:{}", self.server.host, self.server.port),
expected: "a valid socket address".to_string(),
})
}
}
impl Default for AppConfig {
fn default() -> Self {
Self {
server: ServerConfig::default(),
storage: StorageConfig::default(),
auth: AuthConfig::default(),
logging: LoggingConfig::default(),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("invalid value for {field}: '{value}' (expected {expected})")]
InvalidValue {
field: String,
value: String,
expected: String,
},
#[error("missing required field '{field}': {context}")]
MissingField { field: String, context: String },
#[error("failed to read config file {}: {source}", path.display())]
FileError {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to parse config: {0}")]
ParseError(#[from] toml::de::Error),
}
impl From<ConfigError> for AppError {
fn from(e: ConfigError) -> Self {
AppError::new(ErrorKind::Validation, e.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_secret_redaction() {
let secret = Secret::new("my-secret-key".to_string());
assert_eq!(format!("{}", secret), "[REDACTED]");
assert_eq!(format!("{:?}", secret), "[REDACTED]");
assert_eq!(secret.into_inner(), "my-secret-key");
}
#[test]
fn test_default_config() {
let config = AppConfig::default();
assert_eq!(config.server.host, "127.0.0.1");
assert_eq!(config.server.port, 3000);
assert_eq!(config.storage.mode, StorageMode::Memory);
assert!(!config.auth.enabled);
assert_eq!(config.logging.level, "info");
}
#[test]
fn test_config_validation() {
let mut config = AppConfig::default();
assert!(config.validate().is_ok());
config.server.port = 0;
assert!(config.validate().is_err());
config.server.port = 3000;
config.storage.mode = StorageMode::Persistent;
assert!(config.validate().is_err());
config.storage.persistent_path = Some(PathBuf::from("/tmp/data"));
assert!(config.validate().is_ok());
}
#[test]
fn test_storage_mode_from_str() {
assert_eq!(StorageMode::from_str("memory").unwrap(), StorageMode::Memory);
assert_eq!(StorageMode::from_str("persistent").unwrap(), StorageMode::Persistent);
assert_eq!(StorageMode::from_str("http").unwrap(), StorageMode::Http);
assert_eq!(StorageMode::from_str("MEMORY").unwrap(), StorageMode::Memory);
assert!(StorageMode::from_str("invalid").is_err());
}
#[test]
fn test_address_parsing() {
let config = AppConfig::default();
let addr = config.address().unwrap();
assert_eq!(addr.port(), 3000);
}
#[test]
fn test_config_from_toml() {
let toml = r#"
[server]
host = "0.0.0.0"
port = 8080
[storage]
mode = "persistent"
persistent_path = "/tmp/data"
[logging]
level = "debug"
format = "json"
"#;
let config: AppConfig = toml::from_str(toml).unwrap();
assert_eq!(config.server.host, "0.0.0.0");
assert_eq!(config.server.port, 8080);
assert_eq!(config.storage.mode, StorageMode::Persistent);
assert_eq!(config.logging.level, "debug");
assert!(matches!(config.logging.format, LogFormat::Json));
}
}