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 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 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 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 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 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 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 Publish,
108 Subscribe,
109 Psubscribe,
110
111 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 "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 "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 "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 "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 "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 "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 "PUBLISH" => Ok(RedisCommand::Publish),
220 "SUBSCRIBE" => Ok(RedisCommand::Subscribe),
221 "PSUBSCRIBE" => Ok(RedisCommand::Psubscribe),
222
223 "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#[derive(Clone, PartialEq, serde::Deserialize)]
242#[serde(default)]
243pub struct RedisConfig {
244 pub host: String,
245 pub port: u16,
246 pub password: Option<String>,
248 pub tls: bool,
250 pub tls_ca_cert: Option<String>,
252 pub connection_timeout_secs: u64,
254 #[serde(default)]
256 pub reconnect: NetworkRetryPolicy,
257 #[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 #[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 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 pub fn build_url(&self) -> Result<String, CamelError> {
364 let effective_tls = self.effective_tls();
365
366 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 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 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#[cfg(feature = "cluster")]
416#[derive(Debug, Clone, PartialEq, Default)]
417pub struct ClusterConfig {
418 pub nodes: Vec<String>,
420 pub readonly_replicas: bool,
422}
423
424#[cfg(feature = "cluster")]
425impl ClusterConfig {
426 pub fn new(nodes: Vec<String>) -> Self {
428 Self {
429 nodes,
430 readonly_replicas: false,
431 }
432 }
433
434 pub fn with_readonly_replicas(mut self) -> Self {
436 self.readonly_replicas = true;
437 self
438 }
439
440 pub fn is_cluster_mode(&self) -> bool {
442 !self.nodes.is_empty()
443 }
444}
445
446#[derive(Clone)]
473pub struct RedisEndpointConfig {
474 pub host: Option<String>,
477
478 pub port: Option<u16>,
481
482 pub command: RedisCommand,
484
485 pub channels: Vec<String>,
487
488 pub key: Option<String>,
490
491 pub timeout: u64,
493
494 pub password: Option<String>,
496
497 pub db: u8,
499
500 pub ssl: Option<bool>,
504
505 pub reconnect: NetworkRetryPolicy,
508
509 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 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 let (host, port) = if parts.path.starts_with("//") {
547 let path = &parts.path[2..]; if path.is_empty() {
549 (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 (None, None)
579 };
580
581 let command = parts
583 .params
584 .get("command")
585 .map(|s| RedisCommand::from_str(s))
586 .transpose()?
587 .unwrap_or(RedisCommand::Set);
588
589 let channels = parts
591 .params
592 .get("channels")
593 .map(|s| s.split(',').map(String::from).collect())
594 .unwrap_or_default();
595
596 let key = parts.params.get("key").cloned();
598
599 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 let password = parts.params.get("password").cloned();
612
613 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 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 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 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 self.reconnect = defaults.reconnect.clone();
681 self.connection_timeout_secs = defaults.connection_timeout_secs;
683 }
684
685 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 if self.ssl.is_none() {
699 self.ssl = Some(false);
700 }
701 }
702
703 pub fn is_ssl_enabled(&self) -> bool {
707 self.ssl.unwrap_or(false)
708 }
709
710 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) } else {
728 format!("{}://{}:{}/{}", scheme, host, port, self.db) }
730 }
731
732 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), None => self.redis_url(),
748 }
749 }
750
751 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
766pub 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
783pub fn is_idempotent_command(cmd: &RedisCommand) -> bool {
788 matches!(
789 cmd,
790 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 | 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 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 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 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 #[test]
937 fn test_redis_url_encodes_password_with_at_sign() {
938 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 #[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 #[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 #[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 #[test]
1227 fn test_redis_url_with_special_char_password() {
1228 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 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 let result = config.build_url();
1282 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 #[test]
1297 fn test_tls_without_feature_returns_error() {
1298 let config = RedisConfig {
1300 tls: true,
1301 ..RedisConfig::default()
1302 };
1303 let result = config.validate_tls();
1304 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 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, ..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 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 assert_eq!(
1384 config.ssl,
1385 Some(false),
1386 "explicit ssl=false should not be overridden by global tls=true"
1387 );
1388 }
1389
1390 #[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 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 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 assert_eq!(config.host, Some("myhost".to_string()));
1437 assert_eq!(config.port, Some(6380));
1438 }
1439
1440 #[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 assert_eq!(config.host, Some("custom".to_string()));
1461 assert_eq!(config.port, Some(7000));
1462 }
1463
1464 #[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 let mut ep = RedisEndpointConfig::from_uri("redis://?command=GET").unwrap();
1494 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 assert_eq!(
1502 ep.host,
1503 Some("redis-prod".to_string()),
1504 "custom default host should be applied"
1505 );
1506 }
1507
1508 #[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 #[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 #[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 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 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 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 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 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 #[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 #[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 #[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}