Skip to main content

camel_component_redis/
config.rs

1use camel_component_api::CamelError;
2use camel_component_api::NetworkRetryPolicy;
3use camel_component_api::parse_uri;
4use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
5use std::str::FromStr;
6
7#[derive(Debug, Clone, PartialEq)]
8pub enum RedisCommand {
9    // String operations
10    Set,
11    Get,
12    Getset,
13    Setnx,
14    Setex,
15    Mget,
16    Mset,
17    Incr,
18    Incrby,
19    Decr,
20    Decrby,
21    Append,
22    Strlen,
23
24    // Key operations
25    Exists,
26    Del,
27    Expire,
28    Expireat,
29    Pexpire,
30    Pexpireat,
31    Ttl,
32    Keys,
33    Rename,
34    Renamenx,
35    Type,
36    Persist,
37    Move,
38    Sort,
39
40    // List operations
41    Lpush,
42    Rpush,
43    Lpushx,
44    Rpushx,
45    Lpop,
46    Rpop,
47    Blpop,
48    Brpop,
49    Llen,
50    Lrange,
51    Lindex,
52    Linsert,
53    Lset,
54    Lrem,
55    Ltrim,
56    Rpoplpush,
57
58    // Hash operations
59    Hset,
60    Hget,
61    Hsetnx,
62    Hmset,
63    Hmget,
64    Hdel,
65    Hexists,
66    Hlen,
67    Hkeys,
68    Hvals,
69    Hgetall,
70    Hincrby,
71
72    // Set operations
73    Sadd,
74    Srem,
75    Smembers,
76    Scard,
77    Sismember,
78    Spop,
79    Smove,
80    Sinter,
81    Sunion,
82    Sdiff,
83    Sinterstore,
84    Sunionstore,
85    Sdiffstore,
86    Srandmember,
87
88    // Sorted set operations
89    Zadd,
90    Zrem,
91    Zrange,
92    Zrevrange,
93    Zrank,
94    Zrevrank,
95    Zscore,
96    Zcard,
97    Zincrby,
98    Zcount,
99    Zrangebyscore,
100    Zrevrangebyscore,
101    Zremrangebyrank,
102    Zremrangebyscore,
103    Zunionstore,
104    Zinterstore,
105
106    // Pub/Sub operations
107    Publish,
108    Subscribe,
109    Psubscribe,
110
111    // Other operations
112    Ping,
113    Echo,
114}
115
116impl FromStr for RedisCommand {
117    type Err = CamelError;
118
119    fn from_str(s: &str) -> Result<Self, Self::Err> {
120        match s.to_uppercase().as_str() {
121            // String operations
122            "SET" => Ok(RedisCommand::Set),
123            "GET" => Ok(RedisCommand::Get),
124            "GETSET" => Ok(RedisCommand::Getset),
125            "SETNX" => Ok(RedisCommand::Setnx),
126            "SETEX" => Ok(RedisCommand::Setex),
127            "MGET" => Ok(RedisCommand::Mget),
128            "MSET" => Ok(RedisCommand::Mset),
129            "INCR" => Ok(RedisCommand::Incr),
130            "INCRBY" => Ok(RedisCommand::Incrby),
131            "DECR" => Ok(RedisCommand::Decr),
132            "DECRBY" => Ok(RedisCommand::Decrby),
133            "APPEND" => Ok(RedisCommand::Append),
134            "STRLEN" => Ok(RedisCommand::Strlen),
135
136            // Key operations
137            "EXISTS" => Ok(RedisCommand::Exists),
138            "DEL" => Ok(RedisCommand::Del),
139            "EXPIRE" => Ok(RedisCommand::Expire),
140            "EXPIREAT" => Ok(RedisCommand::Expireat),
141            "PEXPIRE" => Ok(RedisCommand::Pexpire),
142            "PEXPIREAT" => Ok(RedisCommand::Pexpireat),
143            "TTL" => Ok(RedisCommand::Ttl),
144            "KEYS" => Ok(RedisCommand::Keys),
145            "RENAME" => Ok(RedisCommand::Rename),
146            "RENAMENX" => Ok(RedisCommand::Renamenx),
147            "TYPE" => Ok(RedisCommand::Type),
148            "PERSIST" => Ok(RedisCommand::Persist),
149            "MOVE" => Ok(RedisCommand::Move),
150            "SORT" => Ok(RedisCommand::Sort),
151
152            // List operations
153            "LPUSH" => Ok(RedisCommand::Lpush),
154            "RPUSH" => Ok(RedisCommand::Rpush),
155            "LPUSHX" => Ok(RedisCommand::Lpushx),
156            "RPUSHX" => Ok(RedisCommand::Rpushx),
157            "LPOP" => Ok(RedisCommand::Lpop),
158            "RPOP" => Ok(RedisCommand::Rpop),
159            "BLPOP" => Ok(RedisCommand::Blpop),
160            "BRPOP" => Ok(RedisCommand::Brpop),
161            "LLEN" => Ok(RedisCommand::Llen),
162            "LRANGE" => Ok(RedisCommand::Lrange),
163            "LINDEX" => Ok(RedisCommand::Lindex),
164            "LINSERT" => Ok(RedisCommand::Linsert),
165            "LSET" => Ok(RedisCommand::Lset),
166            "LREM" => Ok(RedisCommand::Lrem),
167            "LTRIM" => Ok(RedisCommand::Ltrim),
168            "RPOPLPUSH" => Ok(RedisCommand::Rpoplpush),
169
170            // Hash operations
171            "HSET" => Ok(RedisCommand::Hset),
172            "HGET" => Ok(RedisCommand::Hget),
173            "HSETNX" => Ok(RedisCommand::Hsetnx),
174            "HMSET" => Ok(RedisCommand::Hmset),
175            "HMGET" => Ok(RedisCommand::Hmget),
176            "HDEL" => Ok(RedisCommand::Hdel),
177            "HEXISTS" => Ok(RedisCommand::Hexists),
178            "HLEN" => Ok(RedisCommand::Hlen),
179            "HKEYS" => Ok(RedisCommand::Hkeys),
180            "HVALS" => Ok(RedisCommand::Hvals),
181            "HGETALL" => Ok(RedisCommand::Hgetall),
182            "HINCRBY" => Ok(RedisCommand::Hincrby),
183
184            // Set operations
185            "SADD" => Ok(RedisCommand::Sadd),
186            "SREM" => Ok(RedisCommand::Srem),
187            "SMEMBERS" => Ok(RedisCommand::Smembers),
188            "SCARD" => Ok(RedisCommand::Scard),
189            "SISMEMBER" => Ok(RedisCommand::Sismember),
190            "SPOP" => Ok(RedisCommand::Spop),
191            "SMOVE" => Ok(RedisCommand::Smove),
192            "SINTER" => Ok(RedisCommand::Sinter),
193            "SUNION" => Ok(RedisCommand::Sunion),
194            "SDIFF" => Ok(RedisCommand::Sdiff),
195            "SINTERSTORE" => Ok(RedisCommand::Sinterstore),
196            "SUNIONSTORE" => Ok(RedisCommand::Sunionstore),
197            "SDIFFSTORE" => Ok(RedisCommand::Sdiffstore),
198            "SRANDMEMBER" => Ok(RedisCommand::Srandmember),
199
200            // Sorted set operations
201            "ZADD" => Ok(RedisCommand::Zadd),
202            "ZREM" => Ok(RedisCommand::Zrem),
203            "ZRANGE" => Ok(RedisCommand::Zrange),
204            "ZREVRANGE" => Ok(RedisCommand::Zrevrange),
205            "ZRANK" => Ok(RedisCommand::Zrank),
206            "ZREVRANK" => Ok(RedisCommand::Zrevrank),
207            "ZSCORE" => Ok(RedisCommand::Zscore),
208            "ZCARD" => Ok(RedisCommand::Zcard),
209            "ZINCRBY" => Ok(RedisCommand::Zincrby),
210            "ZCOUNT" => Ok(RedisCommand::Zcount),
211            "ZRANGEBYSCORE" => Ok(RedisCommand::Zrangebyscore),
212            "ZREVRANGEBYSCORE" => Ok(RedisCommand::Zrevrangebyscore),
213            "ZREMRANGEBYRANK" => Ok(RedisCommand::Zremrangebyrank),
214            "ZREMRANGEBYSCORE" => Ok(RedisCommand::Zremrangebyscore),
215            "ZUNIONSTORE" => Ok(RedisCommand::Zunionstore),
216            "ZINTERSTORE" => Ok(RedisCommand::Zinterstore),
217
218            // Pub/Sub operations
219            "PUBLISH" => Ok(RedisCommand::Publish),
220            "SUBSCRIBE" => Ok(RedisCommand::Subscribe),
221            "PSUBSCRIBE" => Ok(RedisCommand::Psubscribe),
222
223            // Other operations
224            "PING" => Ok(RedisCommand::Ping),
225            "ECHO" => Ok(RedisCommand::Echo),
226
227            _ => Err(CamelError::InvalidUri(format!(
228                "Unknown Redis command: {}",
229                s
230            ))),
231        }
232    }
233}
234
235// --- RedisConfig (global defaults) ---
236
237/// Global Redis configuration defaults.
238///
239/// This struct holds component-level defaults that can be set via YAML config
240/// and applied to endpoint configurations when specific values aren't provided.
241#[derive(Clone, PartialEq, serde::Deserialize)]
242#[serde(default)]
243pub struct RedisConfig {
244    pub host: String,
245    pub port: u16,
246    /// Redis password for authentication. Default: None.
247    pub password: Option<String>,
248    /// Enable TLS connections. Default: false.
249    pub tls: bool,
250    /// Optional path to a CA certificate file for TLS verification. Default: None.
251    pub tls_ca_cert: Option<String>,
252    /// TLS connection timeout in seconds. Default: 10.
253    pub connection_timeout_secs: u64,
254    /// Reconnection policy for lost Redis connections.
255    #[serde(default)]
256    pub reconnect: NetworkRetryPolicy,
257    /// Cluster node URLs. Empty means single-node mode. Feature-gated behind `cluster`.
258    #[cfg(feature = "cluster")]
259    pub cluster_nodes: Vec<String>,
260}
261
262fn redacted_opt(opt: &Option<String>) -> Option<&'static str> {
263    if opt.is_some() { Some("***") } else { None }
264}
265
266impl std::fmt::Debug for RedisConfig {
267    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
268        let mut s = f.debug_struct("RedisConfig");
269        s.field("host", &self.host)
270            .field("port", &self.port)
271            .field("password", &redacted_opt(&self.password))
272            .field("tls", &self.tls)
273            .field("tls_ca_cert", &redacted_opt(&self.tls_ca_cert))
274            .field("connection_timeout_secs", &self.connection_timeout_secs)
275            .field("reconnect", &self.reconnect);
276        #[cfg(feature = "cluster")]
277        s.field("cluster_nodes", &self.cluster_nodes);
278        s.finish()
279    }
280}
281
282impl Default for RedisConfig {
283    fn default() -> Self {
284        Self {
285            host: "localhost".to_string(),
286            port: 6379,
287            password: None,
288            tls: false,
289            tls_ca_cert: None,
290            connection_timeout_secs: 10,
291            reconnect: NetworkRetryPolicy::default(),
292            #[cfg(feature = "cluster")]
293            cluster_nodes: Vec::new(),
294        }
295    }
296}
297
298impl RedisConfig {
299    pub fn with_host(mut self, v: impl Into<String>) -> Self {
300        self.host = v.into();
301        self
302    }
303
304    pub fn with_port(mut self, v: u16) -> Self {
305        self.port = v;
306        self
307    }
308
309    pub fn with_password(mut self, v: impl Into<String>) -> Self {
310        self.password = Some(v.into());
311        self
312    }
313
314    pub fn with_tls(mut self, v: bool) -> Self {
315        self.tls = v;
316        self
317    }
318
319    pub fn with_tls_ca_cert(mut self, v: impl Into<String>) -> Self {
320        self.tls_ca_cert = Some(v.into());
321        self
322    }
323
324    pub fn with_connection_timeout(mut self, secs: u64) -> Self {
325        self.connection_timeout_secs = secs;
326        self
327    }
328
329    pub fn with_reconnect(mut self, p: NetworkRetryPolicy) -> Self {
330        self.reconnect = p;
331        self
332    }
333
334    /// Set cluster node URLs. Feature-gated behind `cluster`.
335    #[cfg(feature = "cluster")]
336    pub fn with_cluster_nodes(mut self, nodes: Vec<String>) -> Self {
337        self.cluster_nodes = nodes;
338        self
339    }
340
341    /// Returns the effective TLS setting for this config.
342    ///
343    /// TLS is auto-enabled for non-loopback hosts even when `tls` is `false`.
344    /// This allows secure-by-default connections to remote Redis instances
345    /// without requiring explicit TLS configuration.
346    pub fn effective_tls(&self) -> bool {
347        self.tls || {
348            let host = &self.host;
349            let normalized = host.trim_start_matches('[').trim_end_matches(']');
350            normalized != "localhost"
351                && !normalized.starts_with("127.")
352                && normalized != "::1"
353                && normalized != "0.0.0.0"
354                && !normalized.starts_with("::ffff:127.")
355                && !normalized.starts_with("::ffff:0:127.")
356        }
357    }
358
359    /// Build the Redis connection URL from this config.
360    ///
361    /// Passwords are percent-encoded to handle special characters (`@`, `:`, `/`).
362    /// Uses `rediss://` scheme when TLS is enabled (explicitly or auto-detected for non-loopback hosts).
363    pub fn build_url(&self) -> Result<String, CamelError> {
364        let effective_tls = self.effective_tls();
365
366        // Warn when auto-enabling TLS
367        if effective_tls && !self.tls {
368            tracing::warn!(
369                host = %self.host,
370                "Redis auto-enabling TLS for non-loopback host (config had tls=false)"
371            );
372        }
373
374        // Validate TLS feature availability
375        self.validate_tls()?;
376
377        let scheme = if effective_tls { "rediss" } else { "redis" };
378        let auth = if let Some(password) = &self.password {
379            let encoded = utf8_percent_encode(password, NON_ALPHANUMERIC).to_string();
380            format!(":{}@", encoded)
381        } else {
382            String::new()
383        };
384        Ok(format!(
385            "{}://{}{}:{}/0",
386            scheme, auth, self.host, self.port
387        ))
388    }
389
390    /// Validate TLS configuration.
391    ///
392    /// Returns an error when TLS is needed (explicitly enabled or auto-detected
393    /// for non-loopback host) but the `redis` crate was not compiled with TLS
394    /// support (requires one of: `tls-rustls-webpki-roots`,
395    /// `tls-rustls-native-certs`, or `tls-native-tls` feature).
396    pub fn validate_tls(&self) -> Result<(), CamelError> {
397        if self.effective_tls() && !cfg!(feature = "tls") {
398            return Err(CamelError::Config(
399                "TLS requires redis/tls feature (enable one of: tls-rustls-webpki-roots, tls-rustls-native-certs, tls-native-tls)".into(),
400            ));
401        }
402        Ok(())
403    }
404}
405
406// --- ClusterConfig (REDIS-012) ---
407
408/// Configuration for Redis Cluster mode.
409///
410/// Feature-gated behind the `cluster` feature flag.
411/// When `nodes` is non-empty, the component should connect to a Redis Cluster
412/// instead of a single node.
413///
414/// TODO(REDIS-012): cluster connection pooling not yet implemented
415#[cfg(feature = "cluster")]
416#[derive(Debug, Clone, PartialEq, Default)]
417pub struct ClusterConfig {
418    /// Cluster node URLs (e.g. `["redis://node1:6379", "redis://node2:6379"]`).
419    pub nodes: Vec<String>,
420    /// Whether to route read queries to replica nodes. Default: false.
421    pub readonly_replicas: bool,
422}
423
424#[cfg(feature = "cluster")]
425impl ClusterConfig {
426    /// Create a new ClusterConfig with the given node URLs.
427    pub fn new(nodes: Vec<String>) -> Self {
428        Self {
429            nodes,
430            readonly_replicas: false,
431        }
432    }
433
434    /// Enable routing read queries to replica nodes.
435    pub fn with_readonly_replicas(mut self) -> Self {
436        self.readonly_replicas = true;
437        self
438    }
439
440    /// Returns true if cluster mode is enabled (nodes is non-empty).
441    pub fn is_cluster_mode(&self) -> bool {
442        !self.nodes.is_empty()
443    }
444}
445
446// --- RedisEndpointConfig (parsed from URI) ---
447
448/// Configuration parsed from a Redis URI.
449///
450/// Format: `redis://host:port?command=GET&...` or `redis://?command=GET` (no host/port)
451///
452/// # Fields with Global Defaults (Option<T>)
453///
454/// These fields can be set via global defaults in `Camel.toml`. They are `Option<T>`
455/// to distinguish between "not set by URI" (`None`) and "explicitly set by URI" (`Some(v)`).
456/// After calling `apply_defaults()` + `resolve_defaults()`, all are guaranteed `Some`.
457///
458/// - `host` - Redis server hostname
459/// - `port` - Redis server port
460///
461/// # Fields Without Global Defaults
462///
463/// These fields are per-endpoint only and have no global defaults:
464///
465/// - `command` - Redis command to execute (default: SET)
466/// - `channels` - Channels for pub/sub operations (default: empty)
467/// - `key` - Key for operations that require it (default: None)
468/// - `timeout` - Timeout in seconds for blocking operations (default: 1)
469/// - `password` - Redis password for authentication (default: None)
470/// - `db` - Redis database number (default: 0)
471/// - `ssl` - Use TLS connection. `None` means not set (falls back to global config or false).
472#[derive(Clone)]
473pub struct RedisEndpointConfig {
474    /// Redis server hostname. `None` if not set in URI.
475    /// Filled by `apply_defaults()` from global config, then `resolve_defaults()`.
476    pub host: Option<String>,
477
478    /// Redis server port. `None` if not set in URI.
479    /// Filled by `apply_defaults()` from global config, then `resolve_defaults()`.
480    pub port: Option<u16>,
481
482    /// Redis command to execute. Default: SET.
483    pub command: RedisCommand,
484
485    /// Channels for pub/sub operations. Default: empty.
486    pub channels: Vec<String>,
487
488    /// Key for operations that require it. Default: None.
489    pub key: Option<String>,
490
491    /// Timeout in seconds for blocking operations. Default: 1.
492    pub timeout: u64,
493
494    /// Redis password for authentication. Default: None.
495    pub password: Option<String>,
496
497    /// Redis database number. Default: 0.
498    pub db: u8,
499
500    /// Use TLS connection. `None` means not explicitly set in URI.
501    /// Filled by `apply_defaults()` from global config TLS setting.
502    /// After resolution, use `is_ssl_enabled()` for the effective value.
503    pub ssl: Option<bool>,
504
505    /// Reconnection policy for transient Redis errors.
506    /// Filled by `apply_defaults()` from global config.
507    pub reconnect: NetworkRetryPolicy,
508
509    /// Connection timeout in seconds for establishing Redis connections.
510    /// Filled by `apply_defaults()` from global config.
511    pub connection_timeout_secs: u64,
512}
513
514impl std::fmt::Debug for RedisEndpointConfig {
515    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
516        f.debug_struct("RedisEndpointConfig")
517            .field("host", &self.host)
518            .field("port", &self.port)
519            .field("command", &self.command)
520            .field("channels", &self.channels)
521            .field("key", &self.key)
522            .field("timeout", &self.timeout)
523            .field("password", &redacted_opt(&self.password))
524            .field("db", &self.db)
525            .field("ssl", &self.ssl)
526            .field("reconnect", &self.reconnect)
527            .field("connection_timeout_secs", &self.connection_timeout_secs)
528            .finish()
529    }
530}
531
532impl RedisEndpointConfig {
533    pub fn from_uri(uri: &str) -> Result<Self, CamelError> {
534        let parts = parse_uri(uri)?;
535
536        // Support both `redis://` and `rediss://` (TLS) schemes
537        if parts.scheme != "redis" && parts.scheme != "rediss" {
538            return Err(CamelError::InvalidUri(format!(
539                "expected scheme 'redis' or 'rediss', got '{}'",
540                parts.scheme
541            )));
542        }
543
544        // Parse host and port from path (format: //host:port or //host or empty)
545        // Use Option to distinguish "not set in URI" from "set in URI"
546        let (host, port) = if parts.path.starts_with("//") {
547            let path = &parts.path[2..]; // Remove leading //
548            if path.is_empty() {
549                // redis://?command=GET → no host, no port
550                (None, None)
551            } else {
552                let (host_part, port_part) = match path.split_once(':') {
553                    Some((h, p)) => (h, Some(p)),
554                    None => (path, None),
555                };
556                let host = Some(host_part.to_string());
557                let port = port_part
558                    .map(|p| {
559                        let n = p.parse::<u16>().map_err(|_| {
560                            CamelError::InvalidUri(format!(
561                                "invalid port '{}': expected 1-65535",
562                                p
563                            ))
564                        })?;
565                        if n == 0 {
566                            return Err(CamelError::InvalidUri(format!(
567                                "invalid port '{}': expected 1-65535",
568                                p
569                            )));
570                        }
571                        Ok(n)
572                    })
573                    .transpose()?;
574                (host, port)
575            }
576        } else {
577            // No // prefix means no host/port in URI
578            (None, None)
579        };
580
581        // Parse command (default to SET)
582        let command = parts
583            .params
584            .get("command")
585            .map(|s| RedisCommand::from_str(s))
586            .transpose()?
587            .unwrap_or(RedisCommand::Set);
588
589        // Parse channels (comma-separated)
590        let channels = parts
591            .params
592            .get("channels")
593            .map(|s| s.split(',').map(String::from).collect())
594            .unwrap_or_default();
595
596        // Parse key
597        let key = parts.params.get("key").cloned();
598
599        // Parse timeout (default to 1 second if absent, error if present but invalid)
600        let timeout = match parts.params.get("timeout") {
601            Some(s) => s.parse::<u64>().map_err(|_| {
602                CamelError::InvalidUri(format!(
603                    "invalid timeout '{}': expected non-negative integer",
604                    s
605                ))
606            })?,
607            None => 1,
608        };
609
610        // Parse password
611        let password = parts.params.get("password").cloned();
612
613        // Parse db (default to 0 if absent, error if present but invalid)
614        let db = match parts.params.get("db") {
615            Some(s) => s.parse::<u8>().map_err(|_| {
616                CamelError::InvalidUri(format!("invalid db '{}': expected integer 0-255", s))
617            })?,
618            None => 0,
619        };
620
621        // Parse ssl: `rediss://` scheme → Some(true), explicit `ssl=` param → Some(bool), absent → None
622        // If ssl param is present but not a valid boolean, error instead of silently defaulting
623        let ssl = if parts.scheme == "rediss" {
624            Some(true)
625        } else {
626            parts
627                .params
628                .get("ssl")
629                .map(|s| match s.to_lowercase().as_str() {
630                    "true" => Ok(true),
631                    "false" => Ok(false),
632                    _ => Err(CamelError::InvalidUri(format!(
633                        "invalid ssl '{}': expected 'true' or 'false'",
634                        s
635                    ))),
636                })
637                .transpose()?
638        };
639
640        Ok(Self {
641            host,
642            port,
643            command,
644            channels,
645            key,
646            timeout,
647            password,
648            db,
649            ssl,
650            reconnect: NetworkRetryPolicy::default(),
651            connection_timeout_secs: RedisConfig::default().connection_timeout_secs,
652        })
653    }
654
655    /// Apply global defaults to any `None` fields.
656    ///
657    /// This method fills in default values from the provided `RedisConfig` for
658    /// fields that are `None` (not set in URI). It's intended to be called after
659    /// parsing a URI when global component defaults should be applied.
660    pub fn apply_defaults(&mut self, defaults: &RedisConfig) {
661        if self.host.is_none() {
662            self.host = Some(defaults.host.clone());
663        }
664        if self.port.is_none() {
665            self.port = Some(defaults.port);
666        }
667        // TLS from global config applies only when ssl was not explicitly set in URI.
668        // Use effective_tls() so auto-enable for non-loopback hosts propagates to endpoints.
669        if self.ssl.is_none() {
670            let effective = defaults.effective_tls();
671            self.ssl = Some(effective);
672            if effective && !defaults.tls {
673                tracing::warn!(
674                    host = %self.host.as_deref().unwrap_or(""),
675                    "Redis auto-enabling TLS for non-loopback host (config had tls=false)"
676                );
677            }
678        }
679        // Reconnect policy from global config (always applied — URI has no override)
680        self.reconnect = defaults.reconnect.clone();
681        // Connection timeout from global config
682        self.connection_timeout_secs = defaults.connection_timeout_secs;
683    }
684
685    /// Resolve any remaining `None` fields to hardcoded defaults.
686    ///
687    /// This should be called after `apply_defaults()` to ensure all fields
688    /// that can have global defaults are guaranteed to be `Some`.
689    pub fn resolve_defaults(&mut self) {
690        let defaults = RedisConfig::default();
691        if self.host.is_none() {
692            self.host = Some(defaults.host);
693        }
694        if self.port.is_none() {
695            self.port = Some(defaults.port);
696        }
697        // Default ssl to false if not set
698        if self.ssl.is_none() {
699            self.ssl = Some(false);
700        }
701    }
702
703    /// Returns the effective SSL setting after defaults have been applied.
704    ///
705    /// Panics if `resolve_defaults()` has not been called yet (ssl is `None`).
706    pub fn is_ssl_enabled(&self) -> bool {
707        self.ssl.unwrap_or(false)
708    }
709
710    /// Build the Redis connection URL.
711    ///
712    /// After `resolve_defaults()`, host and port are guaranteed `Some`.
713    /// Passwords are URL-encoded to handle special characters (`@`, `:`, `/`).
714    /// Uses `rediss://` scheme when `ssl` is true.
715    pub fn redis_url(&self) -> String {
716        let host = self.host.as_deref().unwrap_or("localhost");
717        let port = self.port.unwrap_or(6379);
718        let scheme = if self.is_ssl_enabled() {
719            "rediss"
720        } else {
721            "redis"
722        };
723
724        if let Some(password) = &self.password {
725            let encoded = utf8_percent_encode(password, NON_ALPHANUMERIC).to_string();
726            format!("{}://:{}@{}:{}/{}", scheme, encoded, host, port, self.db) // allow-secret
727        } else {
728            format!("{}://{}:{}/{}", scheme, host, port, self.db) // allow-secret
729        }
730    }
731
732    /// Build a safe version of the Redis URL with password redacted.
733    ///
734    /// Returns the full URL from `redis_url()` if no password is set,
735    /// otherwise replaces the password with `***`.
736    pub fn redis_url_safe(&self) -> String {
737        let host = self.host.as_deref().unwrap_or("localhost");
738        let port = self.port.unwrap_or(6379);
739        let scheme = if self.is_ssl_enabled() {
740            "rediss"
741        } else {
742            "redis"
743        };
744
745        match &self.password {
746            Some(_) => format!("{}://:***@{}:{}/{}", scheme, host, port, self.db), // allow-secret
747            None => self.redis_url(),
748        }
749    }
750
751    /// Return a concise, secret-safe endpoint identifier for tracing.
752    ///
753    /// Format: `rediss://host:port/db` or `redis://host:port/db` — never contains credentials.
754    pub fn safe_endpoint(&self) -> String {
755        let host = self.host.as_deref().unwrap_or("localhost");
756        let port = self.port.unwrap_or(6379);
757        let scheme = if self.is_ssl_enabled() {
758            "rediss"
759        } else {
760            "redis"
761        };
762        format!("{}://{}:{}/{}", scheme, host, port, self.db)
763    }
764}
765
766// ── Transient error detection ────────────────────────────────────────────────
767
768/// Returns true if the error is a transient transport failure that may be
769/// resolved by reconnecting (e.g. connection reset, timeout, I/O error).
770///
771/// Business errors (WRONGTYPE, NOSCRIPT, etc.) and config errors are NOT transient.
772pub fn is_transient_redis_error(err: &CamelError) -> bool {
773    let msg = err.to_string().to_lowercase();
774    msg.contains("connection")
775        || msg.contains("io error")
776        || msg.contains("timed out")
777        || msg.contains("broken pipe")
778        || msg.contains("connection reset")
779        || msg.contains("eof")
780        || msg.contains("refused")
781}
782
783// ── Command idempotency classification ──────────────────────────────────────
784
785/// Returns true if the command is idempotent (safe to retry without risk of
786/// duplicate side effects). Read commands and some writes (SET, DEL) are idempotent.
787pub fn is_idempotent_command(cmd: &RedisCommand) -> bool {
788    matches!(
789        cmd,
790        // Read-only commands
791        RedisCommand::Get
792            | RedisCommand::Mget
793            | RedisCommand::Exists
794            | RedisCommand::Ttl
795            | RedisCommand::Keys
796            | RedisCommand::Type
797            | RedisCommand::Llen
798            | RedisCommand::Lrange
799            | RedisCommand::Lindex
800            | RedisCommand::Scard
801            | RedisCommand::Smembers
802            | RedisCommand::Sismember
803            | RedisCommand::Srandmember
804            | RedisCommand::Zrange
805            | RedisCommand::Zrevrange
806            | RedisCommand::Zrank
807            | RedisCommand::Zrevrank
808            | RedisCommand::Zscore
809            | RedisCommand::Zcard
810            | RedisCommand::Zcount
811            | RedisCommand::Zrangebyscore
812            | RedisCommand::Zrevrangebyscore
813            | RedisCommand::Hget
814            | RedisCommand::Hmget
815            | RedisCommand::Hexists
816            | RedisCommand::Hlen
817            | RedisCommand::Hkeys
818            | RedisCommand::Hvals
819            | RedisCommand::Hgetall
820            | RedisCommand::Strlen
821            | RedisCommand::Ping
822            | RedisCommand::Echo
823            // Idempotent writes (same result if retried)
824            | RedisCommand::Set
825            | RedisCommand::Del
826            | RedisCommand::Expire
827            | RedisCommand::Expireat
828            | RedisCommand::Pexpire
829            | RedisCommand::Pexpireat
830            | RedisCommand::Persist
831            | RedisCommand::Rename
832            | RedisCommand::Renamenx
833            | RedisCommand::Move
834    )
835}
836
837#[cfg(test)]
838mod tests {
839    use super::*;
840
841    #[test]
842    fn test_config_defaults() {
843        let c = RedisEndpointConfig::from_uri("redis://localhost:6379").unwrap();
844        assert_eq!(c.host, Some("localhost".to_string()));
845        assert_eq!(c.port, Some(6379));
846        assert_eq!(c.command, RedisCommand::Set);
847        assert!(c.channels.is_empty());
848        assert!(c.key.is_none());
849        assert_eq!(c.timeout, 1);
850        assert!(c.password.is_none());
851        assert_eq!(c.db, 0);
852    }
853
854    #[test]
855    fn test_config_no_host_port() {
856        // URI with no host/port in path
857        let c = RedisEndpointConfig::from_uri("redis://?command=GET").unwrap();
858        assert_eq!(c.host, None);
859        assert_eq!(c.port, None);
860        assert_eq!(c.command, RedisCommand::Get);
861    }
862
863    #[test]
864    fn test_config_host_only() {
865        // URI with host but no port
866        let c = RedisEndpointConfig::from_uri("redis://redis-server?command=GET").unwrap();
867        assert_eq!(c.host, Some("redis-server".to_string()));
868        assert_eq!(c.port, None);
869    }
870
871    #[test]
872    fn test_config_host_and_port() {
873        let c = RedisEndpointConfig::from_uri("redis://localhost:6380?command=GET").unwrap();
874        assert_eq!(c.host, Some("localhost".to_string()));
875        assert_eq!(c.port, Some(6380));
876        assert_eq!(c.command, RedisCommand::Get);
877    }
878
879    #[test]
880    fn test_config_wrong_scheme() {
881        assert!(RedisEndpointConfig::from_uri("http://localhost:6379").is_err());
882    }
883
884    #[test]
885    fn test_config_command() {
886        let c = RedisEndpointConfig::from_uri("redis://localhost:6379?command=GET").unwrap();
887        assert_eq!(c.command, RedisCommand::Get);
888    }
889
890    #[test]
891    fn test_config_subscribe() {
892        let c = RedisEndpointConfig::from_uri(
893            "redis://localhost:6379?command=SUBSCRIBE&channels=foo,bar",
894        )
895        .unwrap();
896        assert_eq!(c.command, RedisCommand::Subscribe);
897        assert_eq!(c.channels, vec!["foo".to_string(), "bar".to_string()]);
898    }
899
900    #[test]
901    fn test_config_blpop() {
902        let c = RedisEndpointConfig::from_uri(
903            "redis://localhost:6379?command=BLPOP&key=jobs&timeout=5",
904        )
905        .unwrap();
906        assert_eq!(c.command, RedisCommand::Blpop);
907        assert_eq!(c.key, Some("jobs".to_string()));
908        assert_eq!(c.timeout, 5);
909    }
910
911    #[test]
912    fn test_config_auth_db() {
913        let c =
914            RedisEndpointConfig::from_uri("redis://localhost:6379?password=secret&db=2").unwrap();
915        assert_eq!(c.password, Some("secret".to_string()));
916        assert_eq!(c.db, 2);
917    }
918
919    #[test]
920    fn test_redis_url() {
921        let mut c =
922            RedisEndpointConfig::from_uri("redis://localhost:6379?password=secret&db=2").unwrap();
923        c.resolve_defaults();
924        // Password without special chars is unchanged
925        assert_eq!(c.redis_url(), "redis://:secret@localhost:6379/2");
926    }
927
928    #[test]
929    fn test_redis_url_no_auth() {
930        let mut c = RedisEndpointConfig::from_uri("redis://localhost:6379").unwrap();
931        c.resolve_defaults();
932        assert_eq!(c.redis_url(), "redis://localhost:6379/0");
933    }
934
935    // REDIS-015: Password with special characters is URL-encoded
936    #[test]
937    fn test_redis_url_encodes_password_with_at_sign() {
938        // Use a config with raw password containing @
939        let c = RedisEndpointConfig {
940            host: Some("localhost".to_string()),
941            port: Some(6379),
942            command: RedisCommand::Set,
943            channels: vec![],
944            key: None,
945            timeout: 1,
946            password: Some("pass@word".to_string()),
947            db: 0,
948            ssl: Some(false),
949            reconnect: NetworkRetryPolicy::default(),
950            connection_timeout_secs: 10,
951        };
952        let url = c.redis_url();
953        assert!(
954            url.contains("%40"),
955            "password with @ should be encoded: {}",
956            url
957        );
958        assert!(
959            !url.contains("pass@word"),
960            "raw @ should not appear in URL: {}",
961            url
962        );
963    }
964
965    #[test]
966    fn test_redis_url_encodes_password_with_colon() {
967        let c = RedisEndpointConfig {
968            host: Some("localhost".to_string()),
969            port: Some(6379),
970            command: RedisCommand::Set,
971            channels: vec![],
972            key: None,
973            timeout: 1,
974            password: Some("pass:word".to_string()),
975            db: 0,
976            ssl: Some(false),
977            reconnect: NetworkRetryPolicy::default(),
978            connection_timeout_secs: 10,
979        };
980        let url = c.redis_url();
981        assert!(
982            url.contains("%3A"),
983            "password with : should be encoded: {}",
984            url
985        );
986    }
987
988    #[test]
989    fn test_redis_url_encodes_password_with_slash() {
990        let c = RedisEndpointConfig {
991            host: Some("localhost".to_string()),
992            port: Some(6379),
993            command: RedisCommand::Set,
994            channels: vec![],
995            key: None,
996            timeout: 1,
997            password: Some("pass/word".to_string()),
998            db: 0,
999            ssl: Some(false),
1000            reconnect: NetworkRetryPolicy::default(),
1001            connection_timeout_secs: 10,
1002        };
1003        let url = c.redis_url();
1004        assert!(
1005            url.contains("%2F"),
1006            "password with / should be encoded: {}",
1007            url
1008        );
1009    }
1010
1011    // REDIS-006: TLS support via rediss:// scheme and ssl=true param
1012    #[test]
1013    fn test_rediss_scheme_enables_ssl() {
1014        let c = RedisEndpointConfig::from_uri("rediss://localhost:6379?command=GET").unwrap();
1015        assert_eq!(c.ssl, Some(true), "rediss:// scheme should enable SSL");
1016    }
1017
1018    #[test]
1019    fn test_ssl_true_param_enables_ssl() {
1020        let c =
1021            RedisEndpointConfig::from_uri("redis://localhost:6379?command=GET&ssl=true").unwrap();
1022        assert_eq!(c.ssl, Some(true), "ssl=true should enable SSL");
1023    }
1024
1025    #[test]
1026    fn test_ssl_false_param_does_not_enable_ssl() {
1027        let c =
1028            RedisEndpointConfig::from_uri("redis://localhost:6379?command=GET&ssl=false").unwrap();
1029        assert_eq!(c.ssl, Some(false), "ssl=false should not enable SSL");
1030    }
1031
1032    #[test]
1033    fn test_redis_url_uses_rediss_scheme_when_ssl_enabled() {
1034        let c = RedisEndpointConfig {
1035            host: Some("localhost".to_string()),
1036            port: Some(6379),
1037            command: RedisCommand::Set,
1038            channels: vec![],
1039            key: None,
1040            timeout: 1,
1041            password: None,
1042            db: 0,
1043            ssl: Some(true),
1044            reconnect: NetworkRetryPolicy::default(),
1045            connection_timeout_secs: 10,
1046        };
1047        let url = c.redis_url();
1048        assert!(
1049            url.starts_with("rediss://"),
1050            "SSL config should use rediss:// scheme: {}",
1051            url
1052        );
1053    }
1054
1055    #[test]
1056    fn test_redis_url_uses_redis_scheme_when_ssl_disabled() {
1057        let c = RedisEndpointConfig {
1058            host: Some("localhost".to_string()),
1059            port: Some(6379),
1060            command: RedisCommand::Set,
1061            channels: vec![],
1062            key: None,
1063            timeout: 1,
1064            password: None,
1065            db: 0,
1066            ssl: Some(false),
1067            reconnect: NetworkRetryPolicy::default(),
1068            connection_timeout_secs: 10,
1069        };
1070        let url = c.redis_url();
1071        assert!(
1072            url.starts_with("redis://"),
1073            "non-SSL config should use redis:// scheme: {}",
1074            url
1075        );
1076    }
1077
1078    #[test]
1079    fn test_redis_url_safe_uses_correct_scheme_for_ssl() {
1080        let c_ssl = RedisEndpointConfig {
1081            host: Some("localhost".to_string()),
1082            port: Some(6379),
1083            command: RedisCommand::Set,
1084            channels: vec![],
1085            key: None,
1086            timeout: 1,
1087            password: Some("secret".to_string()),
1088            db: 0,
1089            ssl: Some(true),
1090            reconnect: NetworkRetryPolicy::default(),
1091            connection_timeout_secs: 10,
1092        };
1093        let safe = c_ssl.redis_url_safe();
1094        assert!(
1095            safe.starts_with("rediss://"),
1096            "safe URL should use rediss:// for SSL: {}",
1097            safe
1098        );
1099        assert!(
1100            !safe.contains("secret"),
1101            "safe URL must not contain password"
1102        );
1103
1104        let c_no_ssl = RedisEndpointConfig {
1105            host: Some("localhost".to_string()),
1106            port: Some(6379),
1107            command: RedisCommand::Set,
1108            channels: vec![],
1109            key: None,
1110            timeout: 1,
1111            password: Some("secret".to_string()),
1112            db: 0,
1113            ssl: Some(false),
1114            reconnect: NetworkRetryPolicy::default(),
1115            connection_timeout_secs: 10,
1116        };
1117        let safe = c_no_ssl.redis_url_safe();
1118        assert!(
1119            safe.starts_with("redis://"),
1120            "safe URL should use redis:// for non-SSL: {}",
1121            safe
1122        );
1123    }
1124
1125    // P3/P4: safe_endpoint helper
1126    #[test]
1127    fn test_safe_endpoint_no_credentials() {
1128        let c = RedisEndpointConfig {
1129            host: Some("localhost".to_string()),
1130            port: Some(6379),
1131            command: RedisCommand::Set,
1132            channels: vec![],
1133            key: None,
1134            timeout: 1,
1135            password: Some("supersecret".to_string()),
1136            db: 2,
1137            ssl: Some(false),
1138            reconnect: NetworkRetryPolicy::default(),
1139            connection_timeout_secs: 10,
1140        };
1141        let endpoint = c.safe_endpoint();
1142        assert!(
1143            !endpoint.contains("supersecret"),
1144            "safe endpoint must not contain password"
1145        );
1146        assert!(
1147            endpoint.contains("localhost"),
1148            "safe endpoint should contain host"
1149        );
1150        assert!(
1151            endpoint.contains("6379"),
1152            "safe endpoint should contain port"
1153        );
1154    }
1155
1156    #[test]
1157    fn test_safe_endpoint_uses_correct_scheme() {
1158        let c_ssl = RedisEndpointConfig {
1159            host: Some("localhost".to_string()),
1160            port: Some(6380),
1161            command: RedisCommand::Set,
1162            channels: vec![],
1163            key: None,
1164            timeout: 1,
1165            password: None,
1166            db: 0,
1167            ssl: Some(true),
1168            reconnect: NetworkRetryPolicy::default(),
1169            connection_timeout_secs: 10,
1170        };
1171        assert!(c_ssl.safe_endpoint().starts_with("rediss://"));
1172
1173        let c_plain = RedisEndpointConfig {
1174            host: Some("localhost".to_string()),
1175            port: Some(6380),
1176            command: RedisCommand::Set,
1177            channels: vec![],
1178            key: None,
1179            timeout: 1,
1180            password: None,
1181            db: 0,
1182            ssl: Some(false),
1183            reconnect: NetworkRetryPolicy::default(),
1184            connection_timeout_secs: 10,
1185        };
1186        assert!(c_plain.safe_endpoint().starts_with("redis://"));
1187    }
1188
1189    #[test]
1190    fn test_command_from_str() {
1191        assert!(RedisCommand::from_str("SET").is_ok());
1192        assert_eq!(RedisCommand::from_str("SET").unwrap(), RedisCommand::Set);
1193        assert!(RedisCommand::from_str("get").is_ok());
1194        assert_eq!(RedisCommand::from_str("get").unwrap(), RedisCommand::Get);
1195        assert!(RedisCommand::from_str("UNKNOWN").is_err());
1196    }
1197
1198    // --- RedisConfig tests ---
1199
1200    #[test]
1201    fn test_redis_config_defaults() {
1202        let cfg = RedisConfig::default();
1203        assert_eq!(cfg.host, "localhost");
1204        assert_eq!(cfg.port, 6379);
1205        assert!(cfg.password.is_none());
1206        assert!(!cfg.tls);
1207        assert!(cfg.tls_ca_cert.is_none());
1208    }
1209
1210    #[test]
1211    fn test_redis_config_builder() {
1212        let cfg = RedisConfig::default()
1213            .with_host("redis-prod")
1214            .with_port(6380)
1215            .with_password("secret")
1216            .with_tls(true)
1217            .with_tls_ca_cert("/path/to/ca.pem");
1218        assert_eq!(cfg.host, "redis-prod");
1219        assert_eq!(cfg.port, 6380);
1220        assert_eq!(cfg.password, Some("secret".to_string()));
1221        assert!(cfg.tls);
1222        assert_eq!(cfg.tls_ca_cert, Some("/path/to/ca.pem".to_string()));
1223    }
1224
1225    // REDIS-015: Password with special characters is percent-encoded in build_url()
1226    #[test]
1227    fn test_redis_url_with_special_char_password() {
1228        // password "p@ss!" should be percent-encoded in URL
1229        let config = RedisConfig {
1230            host: "localhost".into(),
1231            port: 6379,
1232            password: Some("p@ss!".into()),
1233            ..RedisConfig::default()
1234        };
1235        let url = config.build_url().unwrap();
1236        // @ is percent-encoded as %40
1237        assert!(
1238            url.contains("%40"),
1239            "password with @ should be encoded: {}",
1240            url
1241        );
1242    }
1243
1244    #[test]
1245    fn test_redis_build_url_encodes_colon_in_password() {
1246        let config = RedisConfig {
1247            host: "localhost".into(),
1248            port: 6379,
1249            password: Some("pass:word".into()),
1250            ..RedisConfig::default()
1251        };
1252        let url = config.build_url().unwrap();
1253        assert!(
1254            url.contains("%3A"),
1255            "password with : should be encoded: {}",
1256            url
1257        );
1258    }
1259
1260    #[test]
1261    fn test_redis_build_url_no_password() {
1262        let config = RedisConfig {
1263            host: "localhost".into(),
1264            port: 6380,
1265            password: None,
1266            ..RedisConfig::default()
1267        };
1268        let url = config.build_url().unwrap();
1269        assert_eq!(url, "redis://localhost:6380/0");
1270    }
1271
1272    #[test]
1273    fn test_redis_build_url_uses_rediss_when_tls_enabled() {
1274        let config = RedisConfig {
1275            host: "localhost".into(),
1276            port: 6379,
1277            tls: true,
1278            ..RedisConfig::default()
1279        };
1280        // Without the tls feature, build_url returns an error
1281        let result = config.build_url();
1282        // Since tls feature is not enabled in Cargo.toml, expect error
1283        assert!(
1284            result.is_err(),
1285            "build_url should error when tls=true but feature not enabled"
1286        );
1287        let err = result.unwrap_err();
1288        assert!(
1289            err.to_string().contains("TLS requires"),
1290            "error should mention TLS requirement: {}",
1291            err
1292        );
1293    }
1294
1295    // REDIS-006: TLS validation
1296    #[test]
1297    fn test_tls_without_feature_returns_error() {
1298        // When tls=true but redis/tls feature not available, expect Err
1299        let config = RedisConfig {
1300            tls: true,
1301            ..RedisConfig::default()
1302        };
1303        let result = config.validate_tls();
1304        // Without the tls feature flag, this should return an error
1305        assert!(
1306            result.is_err(),
1307            "validate_tls should error when tls=true but feature not enabled"
1308        );
1309        let err = result.unwrap_err();
1310        assert!(err.to_string().contains("TLS requires redis/tls feature"));
1311    }
1312
1313    #[test]
1314    fn test_validate_tls_ok_when_disabled() {
1315        let config = RedisConfig {
1316            tls: false,
1317            ..RedisConfig::default()
1318        };
1319        assert!(config.validate_tls().is_ok());
1320    }
1321
1322    #[test]
1323    fn test_apply_defaults_propagates_tls() {
1324        let mut config = RedisEndpointConfig::from_uri("redis://?command=GET").unwrap();
1325        assert_eq!(config.ssl, None);
1326
1327        let defaults = RedisConfig::default().with_tls(true);
1328        config.apply_defaults(&defaults);
1329
1330        assert_eq!(
1331            config.ssl,
1332            Some(true),
1333            "TLS from global defaults should enable ssl on endpoint"
1334        );
1335    }
1336
1337    #[test]
1338    fn test_apply_defaults_auto_enables_tls_for_non_loopback() {
1339        // When host is non-loopback and tls=false, effective_tls() returns true.
1340        // apply_defaults must propagate this to the endpoint's ssl field.
1341        let mut config =
1342            RedisEndpointConfig::from_uri("redis://redis-prod.example.com?command=GET").unwrap();
1343        assert_eq!(config.ssl, None);
1344
1345        let defaults = RedisConfig {
1346            host: "redis-prod.example.com".into(),
1347            tls: false, // explicitly false, but non-loopback → auto-enable
1348            ..RedisConfig::default()
1349        };
1350        config.apply_defaults(&defaults);
1351
1352        assert_eq!(
1353            config.ssl,
1354            Some(true),
1355            "auto-enabled TLS from effective_tls() should propagate to endpoint ssl"
1356        );
1357    }
1358
1359    #[test]
1360    fn test_apply_defaults_propagates_connection_timeout() {
1361        let mut config = RedisEndpointConfig::from_uri("redis://?command=GET").unwrap();
1362
1363        let defaults = RedisConfig::default().with_connection_timeout(30);
1364        config.apply_defaults(&defaults);
1365
1366        assert_eq!(
1367            config.connection_timeout_secs, 30,
1368            "connection_timeout_secs should propagate from global config"
1369        );
1370    }
1371
1372    #[test]
1373    fn test_apply_defaults_preserves_explicit_ssl_false() {
1374        // When ssl is explicitly set via URI (ssl=false), global tls default should NOT override
1375        let mut config =
1376            RedisEndpointConfig::from_uri("redis://localhost:6379?command=GET&ssl=false").unwrap();
1377        assert_eq!(config.ssl, Some(false));
1378
1379        let defaults = RedisConfig::default().with_tls(true);
1380        config.apply_defaults(&defaults);
1381
1382        // ssl=false was explicit in URI, should be preserved
1383        assert_eq!(
1384            config.ssl,
1385            Some(false),
1386            "explicit ssl=false should not be overridden by global tls=true"
1387        );
1388    }
1389
1390    // --- apply_defaults tests ---
1391
1392    #[test]
1393    fn test_apply_defaults_fills_none_fields() {
1394        let mut config = RedisEndpointConfig::from_uri("redis://?command=GET").unwrap();
1395        assert_eq!(config.host, None);
1396        assert_eq!(config.port, None);
1397
1398        let defaults = RedisConfig::default()
1399            .with_host("redis-server")
1400            .with_port(6380);
1401        config.apply_defaults(&defaults);
1402
1403        assert_eq!(config.host, Some("redis-server".to_string()));
1404        assert_eq!(config.port, Some(6380));
1405    }
1406
1407    #[test]
1408    fn test_apply_defaults_preserves_values() {
1409        let mut config = RedisEndpointConfig::from_uri("redis://custom:7000?command=GET").unwrap();
1410        assert_eq!(config.host, Some("custom".to_string()));
1411        assert_eq!(config.port, Some(7000));
1412
1413        let defaults = RedisConfig::default()
1414            .with_host("should-not-override")
1415            .with_port(9999);
1416        config.apply_defaults(&defaults);
1417
1418        // Existing values should be preserved
1419        assert_eq!(config.host, Some("custom".to_string()));
1420        assert_eq!(config.port, Some(7000));
1421    }
1422
1423    #[test]
1424    fn test_apply_defaults_partial_none() {
1425        // Host set, port not set
1426        let mut config = RedisEndpointConfig::from_uri("redis://myhost?command=GET").unwrap();
1427        assert_eq!(config.host, Some("myhost".to_string()));
1428        assert_eq!(config.port, None);
1429
1430        let defaults = RedisConfig::default()
1431            .with_host("default-host")
1432            .with_port(6380);
1433        config.apply_defaults(&defaults);
1434
1435        // Host preserved, port filled
1436        assert_eq!(config.host, Some("myhost".to_string()));
1437        assert_eq!(config.port, Some(6380));
1438    }
1439
1440    // --- resolve_defaults tests ---
1441
1442    #[test]
1443    fn test_resolve_defaults_fills_remaining_nones() {
1444        let mut config = RedisEndpointConfig::from_uri("redis://?command=GET").unwrap();
1445        assert_eq!(config.host, None);
1446        assert_eq!(config.port, None);
1447
1448        config.resolve_defaults();
1449
1450        assert_eq!(config.host, Some("localhost".to_string()));
1451        assert_eq!(config.port, Some(6379));
1452    }
1453
1454    #[test]
1455    fn test_resolve_defaults_preserves_existing() {
1456        let mut config = RedisEndpointConfig::from_uri("redis://custom:7000?command=GET").unwrap();
1457        config.resolve_defaults();
1458
1459        // Already set values should be preserved
1460        assert_eq!(config.host, Some("custom".to_string()));
1461        assert_eq!(config.port, Some(7000));
1462    }
1463
1464    // --- Critical test: catches the original defect where from_uri filled absent
1465    //     params with hardcoded defaults, preventing apply_defaults from working.
1466    #[test]
1467    fn test_redis_url_safe_redacts_password() {
1468        let config =
1469            RedisEndpointConfig::from_uri("redis://localhost:6379?password=mysecret&db=2").unwrap();
1470        let safe = config.redis_url_safe();
1471        assert!(
1472            !safe.contains("mysecret"),
1473            "safe URL must not contain password"
1474        );
1475        assert!(
1476            safe.contains("***"),
1477            "safe URL should contain redaction marker"
1478        );
1479        assert!(safe.contains("localhost"), "safe URL should contain host");
1480        assert!(safe.contains("6379"), "safe URL should contain port");
1481    }
1482
1483    #[test]
1484    fn test_redis_url_safe_no_password() {
1485        let config = RedisEndpointConfig::from_uri("redis://localhost:6379?db=0").unwrap();
1486        let safe = config.redis_url_safe();
1487        assert_eq!(safe, config.redis_url());
1488    }
1489
1490    #[test]
1491    fn test_apply_defaults_with_from_uri_no_host_param() {
1492        // Parse URI without host/port
1493        let mut ep = RedisEndpointConfig::from_uri("redis://?command=GET").unwrap();
1494        // At this point, ep.host and ep.port should be None
1495        assert!(ep.host.is_none(), "host should be None when not in URI");
1496        assert!(ep.port.is_none(), "port should be None when not in URI");
1497
1498        let defaults = RedisConfig::default().with_host("redis-prod");
1499        ep.apply_defaults(&defaults);
1500        // Custom default should be applied
1501        assert_eq!(
1502            ep.host,
1503            Some("redis-prod".to_string()),
1504            "custom default host should be applied"
1505        );
1506    }
1507
1508    // REDIS-002: Transient error detection
1509    #[test]
1510    fn test_is_transient_redis_error_detects_connection_errors() {
1511        assert!(is_transient_redis_error(&CamelError::ProcessorError(
1512            "Connection refused".into()
1513        )));
1514        assert!(is_transient_redis_error(&CamelError::ProcessorError(
1515            "connection reset by peer".into()
1516        )));
1517        assert!(is_transient_redis_error(&CamelError::ProcessorError(
1518            "IO error: broken pipe".into()
1519        )));
1520        assert!(is_transient_redis_error(&CamelError::ProcessorError(
1521            "timed out".into()
1522        )));
1523        assert!(is_transient_redis_error(&CamelError::ProcessorError(
1524            "EOF".into()
1525        )));
1526    }
1527
1528    #[test]
1529    fn test_is_transient_redis_error_rejects_business_errors() {
1530        assert!(!is_transient_redis_error(&CamelError::ProcessorError(
1531            "WRONGTYPE".into()
1532        )));
1533        assert!(!is_transient_redis_error(&CamelError::ProcessorError(
1534            "NOSCRIPT".into()
1535        )));
1536        assert!(!is_transient_redis_error(&CamelError::InvalidUri(
1537            "bad uri".into()
1538        )));
1539        assert!(!is_transient_redis_error(&CamelError::Config(
1540            "bad config".into()
1541        )));
1542    }
1543
1544    // REDIS-002: Idempotency classification
1545    #[test]
1546    fn test_is_idempotent_command_read_commands() {
1547        assert!(is_idempotent_command(&RedisCommand::Get));
1548        assert!(is_idempotent_command(&RedisCommand::Exists));
1549        assert!(is_idempotent_command(&RedisCommand::Ttl));
1550        assert!(is_idempotent_command(&RedisCommand::Ping));
1551    }
1552
1553    #[test]
1554    fn test_is_idempotent_command_idempotent_writes() {
1555        assert!(is_idempotent_command(&RedisCommand::Set));
1556        assert!(is_idempotent_command(&RedisCommand::Del));
1557        assert!(is_idempotent_command(&RedisCommand::Expire));
1558    }
1559
1560    #[test]
1561    fn test_is_idempotent_command_non_idempotent() {
1562        assert!(!is_idempotent_command(&RedisCommand::Incr));
1563        assert!(!is_idempotent_command(&RedisCommand::Decr));
1564        assert!(!is_idempotent_command(&RedisCommand::Lpush));
1565        assert!(!is_idempotent_command(&RedisCommand::Sadd));
1566        assert!(!is_idempotent_command(&RedisCommand::Zadd));
1567        assert!(!is_idempotent_command(&RedisCommand::Publish));
1568    }
1569
1570    // --- Strict validation tests ---
1571
1572    #[test]
1573    fn test_invalid_port_returns_error() {
1574        let result = RedisEndpointConfig::from_uri("redis://localhost:abc?command=GET");
1575        assert!(result.is_err(), "non-numeric port should error");
1576        let err = result.unwrap_err();
1577        let msg = err.to_string();
1578        assert!(
1579            msg.contains("invalid port"),
1580            "error should mention invalid port: {}",
1581            msg
1582        );
1583    }
1584
1585    #[test]
1586    fn test_out_of_range_port_returns_error() {
1587        // u16 max is 65535, but a value like 99999 won't fit
1588        let result = RedisEndpointConfig::from_uri("redis://localhost:99999?command=GET");
1589        assert!(result.is_err(), "out-of-range port should error");
1590    }
1591
1592    #[test]
1593    fn test_invalid_timeout_returns_error() {
1594        let result =
1595            RedisEndpointConfig::from_uri("redis://localhost:6379?command=GET&timeout=abc");
1596        assert!(result.is_err(), "non-numeric timeout should error");
1597        let err = result.unwrap_err();
1598        let msg = err.to_string();
1599        assert!(
1600            msg.contains("invalid timeout"),
1601            "error should mention invalid timeout: {}",
1602            msg
1603        );
1604    }
1605
1606    #[test]
1607    fn test_negative_timeout_returns_error() {
1608        // A negative number won't parse as u64
1609        let result = RedisEndpointConfig::from_uri("redis://localhost:6379?command=GET&timeout=-1");
1610        assert!(result.is_err(), "negative timeout should error");
1611    }
1612
1613    #[test]
1614    fn test_invalid_db_returns_error() {
1615        let result = RedisEndpointConfig::from_uri("redis://localhost:6379?command=GET&db=abc");
1616        assert!(result.is_err(), "non-numeric db should error");
1617        let err = result.unwrap_err();
1618        let msg = err.to_string();
1619        assert!(
1620            msg.contains("invalid db"),
1621            "error should mention invalid db: {}",
1622            msg
1623        );
1624    }
1625
1626    #[test]
1627    fn test_out_of_range_db_returns_error() {
1628        // db is u8 (0-255), 300 won't fit
1629        let result = RedisEndpointConfig::from_uri("redis://localhost:6379?command=GET&db=300");
1630        assert!(result.is_err(), "out-of-range db should error");
1631    }
1632
1633    #[test]
1634    fn test_invalid_ssl_returns_error() {
1635        let result = RedisEndpointConfig::from_uri("redis://localhost:6379?command=GET&ssl=yes");
1636        assert!(result.is_err(), "non-boolean ssl should error");
1637        let err = result.unwrap_err();
1638        let msg = err.to_string();
1639        assert!(
1640            msg.contains("invalid ssl"),
1641            "error should mention invalid ssl: {}",
1642            msg
1643        );
1644    }
1645
1646    #[test]
1647    fn test_valid_params_still_work() {
1648        // Sanity check that valid params still parse correctly after strict validation
1649        let c = RedisEndpointConfig::from_uri(
1650            "redis://localhost:6380?command=GET&timeout=10&db=3&ssl=true",
1651        )
1652        .unwrap();
1653        assert_eq!(c.port, Some(6380));
1654        assert_eq!(c.timeout, 10);
1655        assert_eq!(c.db, 3);
1656        assert_eq!(c.ssl, Some(true));
1657    }
1658
1659    #[test]
1660    fn test_absent_params_use_defaults() {
1661        // When params are absent, defaults should be used (not error)
1662        let c = RedisEndpointConfig::from_uri("redis://localhost?command=GET").unwrap();
1663        assert_eq!(c.timeout, 1);
1664        assert_eq!(c.db, 0);
1665        assert_eq!(c.ssl, None);
1666    }
1667
1668    // --- Debug redaction tests ---
1669
1670    #[test]
1671    fn redis_config_debug_redacts_password() {
1672        let config = RedisConfig::default().with_password("hunter2");
1673        let debug = format!("{:?}", config);
1674        assert!(
1675            !debug.contains("hunter2"),
1676            "password must be redacted in Debug: {debug}"
1677        );
1678        assert!(
1679            debug.contains("password"),
1680            "field name should appear: {debug}"
1681        );
1682        assert!(
1683            debug.contains("***"),
1684            "redacted value should appear: {debug}"
1685        );
1686    }
1687
1688    #[test]
1689    fn redis_config_debug_redacts_tls_ca_cert() {
1690        let config = RedisConfig::default().with_tls_ca_cert("/secret/ca.pem");
1691        let debug = format!("{:?}", config);
1692        assert!(
1693            !debug.contains("/secret/"),
1694            "tls_ca_cert path must be redacted: {debug}"
1695        );
1696    }
1697
1698    #[test]
1699    fn redis_endpoint_config_debug_redacts_password() {
1700        let config = RedisEndpointConfig {
1701            host: Some("localhost".to_string()),
1702            port: Some(6379),
1703            command: RedisCommand::Get,
1704            channels: vec![],
1705            key: Some("mykey".to_string()),
1706            timeout: 1,
1707            password: Some("secret123".to_string()),
1708            db: 0,
1709            ssl: None,
1710            reconnect: NetworkRetryPolicy::default(),
1711            connection_timeout_secs: 10,
1712        };
1713        let debug = format!("{:?}", config);
1714        assert!(
1715            !debug.contains("secret123"),
1716            "password must be redacted: {debug}"
1717        );
1718    }
1719
1720    // ── D-M15: Effective TLS and connection_timeout ────────────────────────────
1721
1722    #[test]
1723    fn test_effective_tls_auto_enabled_for_non_loopback() {
1724        let cfg = RedisConfig {
1725            host: "redis-prod.example.com".into(),
1726            tls: false,
1727            ..RedisConfig::default()
1728        };
1729        assert!(
1730            cfg.effective_tls(),
1731            "TLS should auto-enable for non-loopback host"
1732        );
1733    }
1734
1735    #[test]
1736    fn test_effective_tls_stays_disabled_for_localhost() {
1737        let cfg = RedisConfig {
1738            host: "localhost".into(),
1739            tls: false,
1740            ..RedisConfig::default()
1741        };
1742        assert!(
1743            !cfg.effective_tls(),
1744            "TLS should stay disabled for localhost"
1745        );
1746    }
1747
1748    #[test]
1749    fn test_effective_tls_stays_disabled_for_127_addr() {
1750        let cfg = RedisConfig {
1751            host: "127.0.0.1".into(),
1752            tls: false,
1753            ..RedisConfig::default()
1754        };
1755        assert!(
1756            !cfg.effective_tls(),
1757            "TLS should stay disabled for 127.x.x.x"
1758        );
1759    }
1760
1761    #[test]
1762    fn test_effective_tls_stays_disabled_for_ipv6_loopback() {
1763        let cfg = RedisConfig {
1764            host: "::1".into(),
1765            tls: false,
1766            ..RedisConfig::default()
1767        };
1768        assert!(
1769            !cfg.effective_tls(),
1770            "TLS should stay disabled for ::1 loopback"
1771        );
1772    }
1773
1774    #[test]
1775    fn test_effective_tls_stays_disabled_for_wildcard() {
1776        let cfg = RedisConfig {
1777            host: "0.0.0.0".into(),
1778            tls: false,
1779            ..RedisConfig::default()
1780        };
1781        assert!(
1782            !cfg.effective_tls(),
1783            "TLS should stay disabled for 0.0.0.0 wildcard"
1784        );
1785    }
1786
1787    #[test]
1788    fn test_effective_tls_explicit_true_overrides_loopback() {
1789        let cfg = RedisConfig {
1790            host: "localhost".into(),
1791            tls: true,
1792            ..RedisConfig::default()
1793        };
1794        assert!(
1795            cfg.effective_tls(),
1796            "explicit tls=true should work even on loopback"
1797        );
1798    }
1799
1800    #[test]
1801    fn test_connection_timeout_default_is_10() {
1802        let cfg = RedisConfig::default();
1803        assert_eq!(
1804            cfg.connection_timeout_secs, 10,
1805            "connection_timeout_secs default should be 10"
1806        );
1807    }
1808
1809    #[test]
1810    fn test_connection_timeout_custom_via_builder() {
1811        let cfg = RedisConfig::default().with_connection_timeout(30);
1812        assert_eq!(cfg.connection_timeout_secs, 30);
1813    }
1814
1815    #[test]
1816    #[cfg(not(feature = "tls"))]
1817    fn test_build_url_auto_enables_rediss_for_non_loopback() {
1818        let cfg = RedisConfig {
1819            host: "redis-prod.example.com".into(),
1820            tls: false,
1821            ..RedisConfig::default()
1822        };
1823        let result = cfg.build_url();
1824        assert!(
1825            result.is_err(),
1826            "build_url should error when TLS auto-enabled but feature not compiled"
1827        );
1828    }
1829
1830    #[test]
1831    fn test_build_url_uses_redis_scheme_for_loopback_without_tls() {
1832        let cfg = RedisConfig {
1833            host: "localhost".into(),
1834            tls: false,
1835            ..RedisConfig::default()
1836        };
1837        let url = cfg.build_url().unwrap();
1838        assert!(
1839            url.starts_with("redis://"),
1840            "loopback without tls should use redis:// scheme: {}",
1841            url
1842        );
1843    }
1844
1845    // --- REDIS-012: Cluster config tests ---
1846    #[cfg(feature = "cluster")]
1847    mod cluster_tests {
1848        use super::*;
1849
1850        #[test]
1851        fn test_cluster_config_default_is_empty() {
1852            let cfg = ClusterConfig::default();
1853            assert!(cfg.nodes.is_empty());
1854            assert!(!cfg.readonly_replicas);
1855            assert!(!cfg.is_cluster_mode());
1856        }
1857
1858        #[test]
1859        fn test_cluster_config_new_with_nodes() {
1860            let cfg = ClusterConfig::new(vec![
1861                "redis://node1:6379".to_string(),
1862                "redis://node2:6379".to_string(),
1863            ]);
1864            assert_eq!(cfg.nodes.len(), 2);
1865            assert!(cfg.is_cluster_mode());
1866        }
1867
1868        #[test]
1869        fn test_cluster_config_with_readonly_replicas() {
1870            let cfg =
1871                ClusterConfig::new(vec!["redis://node1:6379".to_string()]).with_readonly_replicas();
1872            assert!(cfg.readonly_replicas);
1873        }
1874    }
1875}