bc1b47d
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<Self> {
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<String>,
pub mode: Option<RedisMode>,
pub database: Option<u16>,
pub pool_size: Option<usize>,
pub connection_timeout_secs: Option<u64>,
pub username: Option<String>,
pub password: Option<String>,
pub key_prefix: Option<String>,
pub default_ttl_secs: Option<u64>,
}
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<Duration> {
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<Vec<String>, 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<String, String> {
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}"))
}
}