use std::time::Duration; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RedisMode { #[default] Standalone, Cluster, ValkeyStandalone, } impl RedisMode { pub fn parse(value: &str) -> Option { if value.eq_ignore_ascii_case("standalone") { Some(Self::Standalone) } else if value.eq_ignore_ascii_case("cluster") { Some(Self::Cluster) } else if value.eq_ignore_ascii_case("valkey_standalone") || value.eq_ignore_ascii_case("valkey") { Some(Self::ValkeyStandalone) } else { None } } } #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct RedisConfig { pub url: Option, pub mode: Option, pub database: Option, pub pool_size: Option, pub connection_timeout_secs: Option, pub username: Option, pub password: Option, pub key_prefix: Option, pub default_ttl_secs: Option, } impl RedisConfig { pub fn url(&self) -> &str { self.url.as_deref().unwrap_or("") } pub fn mode(&self) -> RedisMode { self.mode.unwrap_or_default() } pub fn is_cluster(&self) -> bool { matches!(self.mode(), RedisMode::Cluster) } pub fn database(&self) -> i64 { self.database.map(i64::from).unwrap_or(0) } pub fn pool_size(&self) -> usize { self.pool_size.unwrap_or(8) } pub fn connection_timeout(&self) -> Duration { Duration::from_secs(self.connection_timeout_secs.unwrap_or(2)) } pub fn create_timeout(&self) -> Duration { self.connection_timeout() } pub fn key_prefix(&self) -> &str { self.key_prefix.as_deref().unwrap_or("") } pub fn default_ttl(&self) -> Option { self.default_ttl_secs.map(Duration::from_secs) } /// Full connection URLs for every configured node, in configuration order. /// /// Scheme, embedded credentials, path and IPv6 brackets are preserved so that /// every consumer derives the same connection parameters from the same file. /// Nodes without a scheme are treated as `host[:port]` and default to /// `redis://`. pub fn node_urls(&self) -> Result, String> { let url = self .url .as_deref() .filter(|value| !value.is_empty()) .ok_or_else(|| "redis.url must be set".to_string())?; url.split(',').map(parse_node).collect() } pub fn validate(&self) -> Result<&str, String> { if let Some(size) = self.pool_size { if size == 0 { return Err("redis.pool_size must be at least 1".to_string()); } if size > 128 { return Err(format!("redis.pool_size must not exceed 128, got {size}")); } } if self .key_prefix .as_deref() .is_some_and(|value| value.contains(char::is_whitespace)) { return Err("redis.key_prefix must not contain whitespace".to_string()); } if self.default_ttl_secs == Some(0) { return Err("redis.default_ttl_secs must be greater than 0".to_string()); } let url = self .url .as_deref() .filter(|s| !s.is_empty()) .ok_or_else(|| "redis.url must be set".to_string())?; match self.mode() { RedisMode::Standalone | RedisMode::ValkeyStandalone => { if !has_scheme(url.trim()) || url.contains(',') { return Err(format!( "redis.url must be a single redis:// or rediss:// node, got {url:?}" )); } } RedisMode::Cluster => { self.node_urls()?; } } Ok(url) } } impl crate::loader::Section for RedisConfig { const KEY: &'static str = "redis"; } fn has_scheme(node: &str) -> bool { node.starts_with("redis://") || node.starts_with("rediss://") } fn parse_node(node: &str) -> Result { let trimmed = node.trim(); if trimmed.is_empty() { return Err("redis.url contains an empty entry".to_string()); } if has_scheme(trimmed) { Ok(trimmed.to_string()) } else { Ok(format!("redis://{trimmed}")) } }