Skip to main content

camel_component_ws/
config.rs

1use std::time::Duration;
2
3use camel_component_api::{
4    CamelError, ComponentMetadata, NetworkRetryPolicy, UriConfig, UriOption,
5};
6
7#[derive(Debug, Clone, Default, serde::Deserialize)]
8pub struct WsConfig {
9    pub max_connections: Option<u32>,
10    pub max_message_size: Option<u32>,
11    pub heartbeat_interval_ms: Option<u64>,
12    pub idle_timeout_ms: Option<u64>,
13    pub connect_timeout_ms: Option<u64>,
14    pub response_timeout_ms: Option<u64>,
15    pub send_timeout_ms: Option<u64>,
16    pub binary_payload: Option<bool>,
17    pub subprotocols: Option<Vec<String>>,
18}
19
20/// Private container for macro-derived `uri_options()` and `metadata()`.
21///
22/// Mirrors `WsEndpointConfig`'s URI-parsed fields using `FromStr`-compatible
23/// types (`u64` for Duration fields, `String` for `Vec<String>`).
24/// `WsEndpointConfig` holds non-URI fields (`scheme`, `host`, `port`, `path`,
25/// `reconnect_policy`) appended; metadata delegation targets this inner type.
26#[derive(Debug, Clone, UriConfig)]
27#[allow(dead_code)]
28#[uri_scheme = "ws"]
29#[uri_config(
30    skip_impl,
31    metadata(
32        scheme = "ws",
33        description = "WebSocket client/server endpoint",
34        producer,
35        consumer
36    ),
37    crate = "camel_component_api"
38)]
39struct WsUriConfig {
40    #[allow(dead_code)]
41    _path: String,
42    #[uri_param(
43        name = "maxConnections",
44        default = "100",
45        desc = "Maximum concurrent WebSocket connections"
46    )]
47    max_connections: u32,
48    #[uri_param(
49        name = "maxMessageSize",
50        default = "65536",
51        desc = "Maximum WebSocket message size in bytes"
52    )]
53    max_message_size: u32,
54    #[uri_param(
55        name = "sendToAll",
56        default = "false",
57        desc = "Broadcast messages to all connected clients"
58    )]
59    send_to_all: bool,
60    #[uri_param(
61        name = "heartbeatIntervalMs",
62        default = "0",
63        desc = "Heartbeat ping interval in milliseconds"
64    )]
65    heartbeat_interval_ms: u64,
66    #[uri_param(
67        name = "idleTimeoutMs",
68        default = "0",
69        desc = "Connection idle timeout in milliseconds"
70    )]
71    idle_timeout_ms: u64,
72    #[uri_param(
73        name = "connectTimeoutMs",
74        default = "10000",
75        desc = "Connection timeout in milliseconds"
76    )]
77    connect_timeout_ms: u64,
78    #[uri_param(
79        name = "responseTimeoutMs",
80        default = "30000",
81        desc = "Response timeout in milliseconds"
82    )]
83    response_timeout_ms: u64,
84    #[uri_param(
85        name = "allowOrigin",
86        default = "*",
87        desc = "CORS origin allowed for WebSocket upgrade"
88    )]
89    allow_origin: String,
90    #[uri_param(name = "tlsCert", desc = "Path to TLS certificate file")]
91    tls_cert: Option<String>,
92    #[uri_param(name = "tlsKey", desc = "Path to TLS private key file")]
93    tls_key: Option<String>,
94    #[uri_param(
95        name = "reconnect",
96        default = "true",
97        desc = "Enable reconnection for client producer"
98    )]
99    reconnect: bool,
100    #[uri_param(
101        name = "reconnectMaxAttempts",
102        default = "5",
103        desc = "Maximum reconnection attempts"
104    )]
105    reconnect_max_attempts: u32,
106    #[uri_param(
107        name = "reconnectDelayMs",
108        default = "1000",
109        desc = "Delay between reconnection attempts"
110    )]
111    reconnect_delay_ms: u64,
112    #[uri_param(
113        name = "sendTimeoutMs",
114        default = "30000",
115        desc = "Send timeout in milliseconds"
116    )]
117    send_timeout_ms: u64,
118    #[uri_param(
119        name = "binaryPayload",
120        default = "false",
121        desc = "Use binary payload mode"
122    )]
123    binary_payload: bool,
124    #[uri_param(name = "subprotocols", desc = "Comma-separated WebSocket subprotocols")]
125    subprotocols_str: String,
126}
127
128#[derive(Clone)]
129pub struct WsEndpointConfig {
130    pub scheme: String,
131    pub host: String,
132    pub port: u16,
133    pub path: String,
134    pub max_connections: u32,
135    pub max_message_size: u32,
136    pub send_to_all: bool,
137    pub heartbeat_interval: Duration,
138    pub idle_timeout: Duration,
139    pub connect_timeout: Duration,
140    pub response_timeout: Duration,
141    pub allow_origin: String,
142    pub tls_cert: Option<String>,
143    pub tls_key: Option<String>,
144    pub reconnect: bool,
145    pub reconnect_max_attempts: u32,
146    pub reconnect_delay_ms: u64,
147    pub send_timeout: Duration,
148    pub binary_payload: bool,
149    pub subprotocols: Vec<String>,
150    /// Structured reconnection policy, replacing the flat `reconnect`/
151    /// `reconnect_max_attempts`/`reconnect_delay_ms` fields for new config.
152    /// The flat fields remain as a backward-compat shim and are bridged into
153    /// this policy during `from_uri()` construction.
154    pub reconnect_policy: NetworkRetryPolicy,
155}
156
157fn redacted_opt(opt: &Option<String>) -> Option<&'static str> {
158    if opt.is_some() { Some("***") } else { None }
159}
160
161impl std::fmt::Debug for WsEndpointConfig {
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        f.debug_struct("WsEndpointConfig")
164            .field("scheme", &self.scheme)
165            .field("host", &self.host)
166            .field("port", &self.port)
167            .field("path", &self.path)
168            .field("max_connections", &self.max_connections)
169            .field("max_message_size", &self.max_message_size)
170            .field("send_to_all", &self.send_to_all)
171            .field("heartbeat_interval", &self.heartbeat_interval)
172            .field("idle_timeout", &self.idle_timeout)
173            .field("connect_timeout", &self.connect_timeout)
174            .field("response_timeout", &self.response_timeout)
175            .field("allow_origin", &self.allow_origin)
176            .field("tls_cert", &redacted_opt(&self.tls_cert))
177            .field("tls_key", &redacted_opt(&self.tls_key))
178            .field("reconnect", &self.reconnect)
179            .field("reconnect_max_attempts", &self.reconnect_max_attempts)
180            .field("reconnect_delay_ms", &self.reconnect_delay_ms)
181            .field("reconnect_policy", &self.reconnect_policy)
182            .field("send_timeout", &self.send_timeout)
183            .field("binary_payload", &self.binary_payload)
184            .field("subprotocols", &self.subprotocols)
185            .finish()
186    }
187}
188
189impl Default for WsEndpointConfig {
190    fn default() -> Self {
191        Self {
192            scheme: "ws".into(),
193            host: "0.0.0.0".into(),
194            port: 8080,
195            path: "/".into(),
196            max_connections: 100,
197            max_message_size: 65536,
198            send_to_all: false,
199            heartbeat_interval: Duration::ZERO,
200            idle_timeout: Duration::ZERO,
201            connect_timeout: Duration::from_secs(10),
202            response_timeout: Duration::from_secs(30),
203            allow_origin: "*".into(),
204            tls_cert: None,
205            tls_key: None,
206            reconnect: true,
207            reconnect_max_attempts: 5,
208            reconnect_delay_ms: 1000,
209            send_timeout: Duration::from_secs(30),
210            binary_payload: false,
211            subprotocols: Vec::new(),
212            reconnect_policy: NetworkRetryPolicy {
213                enabled: true,
214                max_attempts: 5,
215                initial_delay: Duration::from_millis(1000),
216                multiplier: 2.0,
217                max_delay: Duration::from_secs(30),
218                jitter_factor: 0.0, // old behavior had no jitter
219                max_attempts_absolute: None,
220            },
221        }
222    }
223}
224
225#[derive(Debug, Clone)]
226pub struct WsServerConfig {
227    pub inner: WsEndpointConfig,
228}
229
230#[derive(Debug, Clone)]
231pub struct WsClientConfig {
232    pub inner: WsEndpointConfig,
233}
234
235impl WsConfig {
236    /// Validate configuration values.
237    ///
238    /// Returns an error if any explicitly-set value is invalid (e.g. zero).
239    /// `None` values are valid — they mean "use the default / unlimited".
240    pub fn validate(&self) -> Result<(), CamelError> {
241        if let Some(0) = self.max_connections {
242            return Err(CamelError::Config(
243                "maxConnections must be >= 1 when specified".into(),
244            ));
245        }
246        if let Some(0) = self.max_message_size {
247            return Err(CamelError::Config(
248                "maxMessageSize must be >= 1 when specified".into(),
249            ));
250        }
251        Ok(())
252    }
253}
254
255impl WsEndpointConfig {
256    pub fn from_uri(uri: &str) -> Result<Self, CamelError> {
257        let parsed = camel_component_api::parse_uri(uri)
258            .map_err(|e| CamelError::EndpointCreationFailed(e.to_string()))?;
259
260        let scheme = parsed.scheme;
261        if scheme != "ws" && scheme != "wss" {
262            return Err(CamelError::EndpointCreationFailed(format!(
263                "Invalid WebSocket scheme: {scheme}"
264            )));
265        }
266
267        let host_port_path = parsed.path;
268        let host_port_path = host_port_path.strip_prefix("//").unwrap_or(&host_port_path);
269        let (host_port, path) = match host_port_path.split_once('/') {
270            Some((hp, p)) => (hp, format!("/{p}")),
271            None => (host_port_path, "/".to_string()),
272        };
273
274        let (host, port) = match host_port.rsplit_once(':') {
275            Some((h, p)) if p.parse::<u16>().is_ok() => {
276                let parsed_port = p.parse::<u16>().unwrap(); // allow-unwrap
277                (h.to_string(), parsed_port)
278            }
279            _ => (
280                host_port.to_string(),
281                if scheme == "wss" { 443 } else { 80 },
282            ),
283        };
284
285        let mut cfg = Self {
286            scheme,
287            host: if host.is_empty() {
288                "0.0.0.0".to_string()
289            } else {
290                host
291            },
292            port,
293            path,
294            ..Self::default()
295        };
296
297        let params = parsed.params;
298        // Validate maxConnections >= 1 (WS-015)
299        if let Some(raw) = params.get("maxConnections") {
300            let v = raw.parse::<u32>().map_err(|_| {
301                CamelError::InvalidUri(format!(
302                    "maxConnections must be an unsigned integer, got '{raw}'"
303                ))
304            })?;
305            if v == 0 {
306                return Err(CamelError::InvalidUri("maxConnections must be >= 1".into()));
307            }
308            cfg.max_connections = v;
309        }
310        // Validate maxMessageSize > 0 (WS-019)
311        if let Some(raw) = params.get("maxMessageSize") {
312            let v = raw.parse::<u32>().map_err(|_| {
313                CamelError::InvalidUri(format!(
314                    "maxMessageSize must be an unsigned integer, got '{raw}'"
315                ))
316            })?;
317            if v == 0 {
318                return Err(CamelError::InvalidUri("maxMessageSize must be > 0".into()));
319            }
320            cfg.max_message_size = v;
321        }
322        if let Some(raw) = params.get("sendToAll") {
323            let v = raw.parse::<bool>().map_err(|_| {
324                CamelError::InvalidUri(format!(
325                    "sendToAll must be a boolean ('true' or 'false'), got '{raw}'"
326                ))
327            })?;
328            cfg.send_to_all = v;
329        }
330        if let Some(raw) = params.get("heartbeatIntervalMs") {
331            let v = raw.parse::<u64>().map_err(|_| {
332                CamelError::InvalidUri(format!(
333                    "heartbeatIntervalMs must be an unsigned integer, got '{raw}'"
334                ))
335            })?;
336            cfg.heartbeat_interval = Duration::from_millis(v);
337        }
338        if let Some(raw) = params.get("idleTimeoutMs") {
339            let v = raw.parse::<u64>().map_err(|_| {
340                CamelError::InvalidUri(format!(
341                    "idleTimeoutMs must be an unsigned integer, got '{raw}'"
342                ))
343            })?;
344            cfg.idle_timeout = Duration::from_millis(v);
345        }
346        if let Some(raw) = params.get("connectTimeoutMs") {
347            let v = raw.parse::<u64>().map_err(|_| {
348                CamelError::InvalidUri(format!(
349                    "connectTimeoutMs must be an unsigned integer, got '{raw}'"
350                ))
351            })?;
352            cfg.connect_timeout = Duration::from_millis(v);
353        }
354        if let Some(raw) = params.get("responseTimeoutMs") {
355            let v = raw.parse::<u64>().map_err(|_| {
356                CamelError::InvalidUri(format!(
357                    "responseTimeoutMs must be an unsigned integer, got '{raw}'"
358                ))
359            })?;
360            cfg.response_timeout = Duration::from_millis(v);
361        }
362        if let Some(v) = params.get("allowOrigin") {
363            if v.is_empty() {
364                return Err(CamelError::InvalidUri(
365                    "allowOrigin must not be empty when specified".into(),
366                ));
367            }
368            cfg.allow_origin = v.to_string();
369        }
370        if let Some(v) = params.get("tlsCert") {
371            cfg.tls_cert = Some(v.to_string());
372        }
373        if let Some(v) = params.get("tlsKey") {
374            cfg.tls_key = Some(v.to_string());
375        }
376        // Track whether flat reconnect fields were explicitly provided.
377        // Used later for conditional bridging into reconnect_policy:
378        // explicit URI params win, otherwise the structured policy (set
379        // via TOML [reconnect_policy] or defaults) is left untouched.
380        let mut reconnect_explicit = false;
381        let mut reconnect_max_attempts_explicit = false;
382        let mut reconnect_delay_ms_explicit = false;
383
384        if let Some(raw) = params.get("reconnect") {
385            cfg.reconnect = raw.parse::<bool>().map_err(|_| {
386                CamelError::InvalidUri(format!(
387                    "reconnect must be a boolean ('true' or 'false'), got '{raw}'"
388                ))
389            })?;
390            reconnect_explicit = true;
391        }
392        if let Some(raw) = params.get("reconnectMaxAttempts") {
393            cfg.reconnect_max_attempts = raw.parse::<u32>().map_err(|_| {
394                CamelError::InvalidUri(format!(
395                    "reconnectMaxAttempts must be an unsigned integer, got '{raw}'"
396                ))
397            })?;
398            reconnect_max_attempts_explicit = true;
399        }
400        if let Some(raw) = params.get("reconnectDelayMs") {
401            cfg.reconnect_delay_ms = raw.parse::<u64>().map_err(|_| {
402                CamelError::InvalidUri(format!(
403                    "reconnectDelayMs must be an unsigned integer, got '{raw}'"
404                ))
405            })?;
406            reconnect_delay_ms_explicit = true;
407        }
408        if let Some(raw) = params.get("sendTimeoutMs") {
409            let v = raw.parse::<u64>().map_err(|_| {
410                CamelError::InvalidUri(format!(
411                    "sendTimeoutMs must be an unsigned integer, got '{raw}'"
412                ))
413            })?;
414            cfg.send_timeout = Duration::from_millis(v);
415        }
416        if let Some(raw) = params.get("binaryPayload") {
417            cfg.binary_payload = raw.parse::<bool>().map_err(|_| {
418                CamelError::InvalidUri(format!(
419                    "binaryPayload must be a boolean ('true' or 'false'), got '{raw}'"
420                ))
421            })?;
422        }
423        if let Some(raw) = params.get("subprotocols") {
424            cfg.subprotocols = raw
425                .split(',')
426                .map(|s| s.trim().to_string())
427                .filter(|s| !s.is_empty())
428                .collect();
429        }
430
431        // Bridge flat reconnect fields into the structured policy, but
432        // ONLY for fields that were explicitly provided in the URI.
433        //
434        // Precedence: if the user provides a [reconnect_policy] section
435        // (TOML) or explicit URI params for the policy, that wins over
436        // the legacy flat fields. When flat fields are NOT explicitly
437        // set, the structured policy is left untouched — preserving
438        // whatever was set via TOML or component defaults.
439        if reconnect_explicit {
440            cfg.reconnect_policy.enabled = cfg.reconnect;
441        }
442        if reconnect_max_attempts_explicit {
443            cfg.reconnect_policy.max_attempts = cfg.reconnect_max_attempts;
444        }
445        if reconnect_delay_ms_explicit {
446            cfg.reconnect_policy.initial_delay = Duration::from_millis(cfg.reconnect_delay_ms);
447        }
448
449        Ok(cfg)
450    }
451
452    pub fn server_config(&self) -> WsServerConfig {
453        WsServerConfig {
454            inner: self.clone(),
455        }
456    }
457
458    pub fn client_config(&self) -> WsClientConfig {
459        WsClientConfig {
460            inner: self.clone(),
461        }
462    }
463
464    pub fn canonical_host(&self) -> String {
465        match self.host.as_str() {
466            "0.0.0.0" | "localhost" => "127.0.0.1".to_string(),
467            h => h.to_string(),
468        }
469    }
470
471    /// Component metadata for the ws scheme, derived from `#[uri_param]`
472    /// annotations on `WsUriConfig`.
473    pub fn metadata() -> ComponentMetadata {
474        WsUriConfig::metadata()
475    }
476
477    /// Generated URI option definitions for the ws scheme, derived from
478    /// `#[uri_param]` annotations on `WsUriConfig`.
479    pub fn uri_options() -> Vec<UriOption> {
480        WsUriConfig::uri_options()
481    }
482}
483
484#[cfg(test)]
485mod config_validation_tests {
486    use super::*;
487
488    #[test]
489    fn test_rejects_zero_max_connections() {
490        let cfg = WsConfig {
491            max_connections: Some(0),
492            ..WsConfig::default()
493        };
494        assert!(cfg.validate().is_err());
495    }
496
497    #[test]
498    fn test_rejects_zero_max_message_size() {
499        let cfg = WsConfig {
500            max_message_size: Some(0),
501            ..WsConfig::default()
502        };
503        assert!(cfg.validate().is_err());
504    }
505
506    #[test]
507    fn test_accepts_valid_config() {
508        let cfg = WsConfig::default();
509        assert!(cfg.validate().is_ok());
510    }
511
512    #[test]
513    fn test_accepts_nonzero_max_connections() {
514        let cfg = WsConfig {
515            max_connections: Some(50),
516            ..WsConfig::default()
517        };
518        assert!(cfg.validate().is_ok());
519    }
520
521    #[test]
522    fn test_accepts_nonzero_max_message_size() {
523        let cfg = WsConfig {
524            max_message_size: Some(1024),
525            ..WsConfig::default()
526        };
527        assert!(cfg.validate().is_ok());
528    }
529
530    #[test]
531    fn test_from_uri_rejects_invalid_send_to_all() {
532        let err = WsEndpointConfig::from_uri("ws://localhost:8080?sendToAll=yes").unwrap_err();
533        assert!(err.to_string().contains("sendToAll"));
534    }
535
536    #[test]
537    fn test_from_uri_rejects_invalid_max_connections_numeric() {
538        let err = WsEndpointConfig::from_uri("ws://localhost:8080?maxConnections=abc").unwrap_err();
539        assert!(err.to_string().contains("maxConnections"));
540    }
541
542    #[test]
543    fn test_from_uri_rejects_invalid_max_message_size_numeric() {
544        let err = WsEndpointConfig::from_uri("ws://localhost:8080?maxMessageSize=abc").unwrap_err();
545        assert!(err.to_string().contains("maxMessageSize"));
546    }
547
548    #[test]
549    fn test_from_uri_rejects_invalid_heartbeat_interval_numeric() {
550        let err =
551            WsEndpointConfig::from_uri("ws://localhost:8080?heartbeatIntervalMs=abc").unwrap_err();
552        assert!(err.to_string().contains("heartbeatIntervalMs"));
553    }
554
555    #[test]
556    fn test_from_uri_rejects_invalid_idle_timeout_numeric() {
557        let err = WsEndpointConfig::from_uri("ws://localhost:8080?idleTimeoutMs=abc").unwrap_err();
558        assert!(err.to_string().contains("idleTimeoutMs"));
559    }
560
561    #[test]
562    fn test_from_uri_rejects_invalid_connect_timeout_numeric() {
563        let err =
564            WsEndpointConfig::from_uri("ws://localhost:8080?connectTimeoutMs=abc").unwrap_err();
565        assert!(err.to_string().contains("connectTimeoutMs"));
566    }
567
568    #[test]
569    fn test_from_uri_rejects_invalid_response_timeout_numeric() {
570        let err =
571            WsEndpointConfig::from_uri("ws://localhost:8080?responseTimeoutMs=abc").unwrap_err();
572        assert!(err.to_string().contains("responseTimeoutMs"));
573    }
574
575    // WS-017: sendTimeoutMs parsing
576    #[test]
577    fn test_from_uri_parses_send_timeout_ms() {
578        let cfg = WsEndpointConfig::from_uri("ws://localhost:8080?sendTimeoutMs=7500").unwrap();
579        assert_eq!(cfg.send_timeout, Duration::from_millis(7500));
580    }
581
582    #[test]
583    fn test_from_uri_rejects_invalid_send_timeout_ms() {
584        let err = WsEndpointConfig::from_uri("ws://localhost:8080?sendTimeoutMs=xyz").unwrap_err();
585        assert!(err.to_string().contains("sendTimeoutMs"));
586    }
587
588    // WS-018: binaryPayload parsing
589    #[test]
590    fn test_from_uri_parses_binary_payload_true() {
591        let cfg = WsEndpointConfig::from_uri("ws://localhost:8080?binaryPayload=true").unwrap();
592        assert!(cfg.binary_payload);
593    }
594
595    #[test]
596    fn test_from_uri_parses_binary_payload_false() {
597        let cfg = WsEndpointConfig::from_uri("ws://localhost:8080?binaryPayload=false").unwrap();
598        assert!(!cfg.binary_payload);
599    }
600
601    #[test]
602    fn test_from_uri_rejects_invalid_binary_payload() {
603        let err = WsEndpointConfig::from_uri("ws://localhost:8080?binaryPayload=sure").unwrap_err();
604        assert!(err.to_string().contains("binaryPayload"));
605    }
606
607    // WS-007: subprotocols parsing
608    #[test]
609    fn test_from_uri_parses_subprotocols() {
610        let cfg =
611            WsEndpointConfig::from_uri("ws://localhost:8080?subprotocols=json,protobuf").unwrap();
612        assert_eq!(cfg.subprotocols, vec!["json", "protobuf"]);
613    }
614
615    #[test]
616    fn test_from_uri_subprotocols_trims_whitespace() {
617        let cfg = WsEndpointConfig::from_uri("ws://localhost:8080?subprotocols=a, b").unwrap();
618        assert_eq!(cfg.subprotocols, vec!["a", "b"]);
619    }
620
621    #[test]
622    fn test_from_uri_subprotocols_empty_when_not_specified() {
623        let cfg = WsEndpointConfig::from_uri("ws://localhost:8080").unwrap();
624        assert!(cfg.subprotocols.is_empty());
625    }
626
627    #[test]
628    fn ws_endpoint_config_debug_redacts_tls() {
629        let config = WsEndpointConfig {
630            tls_cert: Some("/secret/cert.pem".to_string()),
631            tls_key: Some("/secret/key.pem".to_string()),
632            ..WsEndpointConfig::default()
633        };
634        let debug = format!("{:?}", config);
635        assert!(
636            !debug.contains("/secret/"),
637            "TLS paths must be redacted: {debug}"
638        );
639        assert!(
640            debug.contains("tls_cert"),
641            "field name should appear: {debug}"
642        );
643        assert!(
644            debug.contains("tls_key"),
645            "field name should appear: {debug}"
646        );
647    }
648
649    #[test]
650    fn ws_endpoint_config_has_reconnect_policy_field() {
651        let cfg = WsEndpointConfig::default();
652        assert!(cfg.reconnect_policy.enabled);
653        assert_eq!(cfg.reconnect_policy.max_attempts, 5);
654        assert_eq!(
655            cfg.reconnect_policy.initial_delay,
656            std::time::Duration::from_millis(1000)
657        );
658    }
659
660    #[test]
661    fn ws_endpoint_uri_bridges_flat_fields_to_policy() {
662        // Non-default values — verify they land in reconnect_policy.
663        let uri =
664            "ws://localhost:9001/test?reconnect=false&reconnectMaxAttempts=7&reconnectDelayMs=250";
665        let cfg = WsEndpointConfig::from_uri(uri).unwrap();
666        assert!(!cfg.reconnect);
667        assert_eq!(cfg.reconnect_max_attempts, 7);
668        assert_eq!(cfg.reconnect_delay_ms, 250);
669        // Bridge should override reconnect_policy defaults.
670        assert!(!cfg.reconnect_policy.enabled);
671        assert_eq!(cfg.reconnect_policy.max_attempts, 7);
672        assert_eq!(
673            cfg.reconnect_policy.initial_delay,
674            std::time::Duration::from_millis(250)
675        );
676    }
677
678    #[test]
679    fn ws_endpoint_policy_defaults_match_old_flat_defaults() {
680        let cfg = WsEndpointConfig::default();
681        assert!(cfg.reconnect_policy.enabled);
682        assert_eq!(cfg.reconnect_policy.max_attempts, 5);
683        assert_eq!(
684            cfg.reconnect_policy.initial_delay,
685            std::time::Duration::from_millis(1000)
686        );
687        assert!((cfg.reconnect_policy.multiplier - 2.0).abs() < f64::EPSILON);
688        assert_eq!(
689            cfg.reconnect_policy.max_delay,
690            std::time::Duration::from_secs(30)
691        );
692        assert!((cfg.reconnect_policy.jitter_factor - 0.0).abs() < f64::EPSILON);
693    }
694
695    // ── Precedence regression tests ──────────────────────────────────────
696
697    /// When flat reconnect fields are NOT provided in the URI, the structured
698    /// `reconnect_policy` field (set via `WsEndpointConfig::default()` or, in
699    /// the future, TOML `[reconnect_policy]`) MUST NOT be overwritten.
700    #[test]
701    fn ws_endpoint_policy_preserved_when_no_flat_fields() {
702        let cfg = WsEndpointConfig::from_uri("ws://localhost:8080/echo").unwrap();
703        // Default policy should be unchanged — no URI reconnect params.
704        assert!(cfg.reconnect_policy.enabled);
705        assert_eq!(cfg.reconnect_policy.max_attempts, 5);
706        assert_eq!(
707            cfg.reconnect_policy.initial_delay,
708            std::time::Duration::from_millis(1000)
709        );
710    }
711
712    /// When a custom `reconnect_policy` is set on `WsEndpointConfig`
713    /// (simulating TOML `[reconnect_policy]` deserialization), calling
714    /// `from_uri()` without flat reconnect params MUST NOT silently
715    /// reset the policy.
716    ///
717    /// This test covers the TOML path for `[reconnect_policy]`:
718    /// `WsEndpointConfig` is not directly TOML‑deserializable today,
719    /// but if a caller constructs it with a pre‑built policy (e.g. from
720    /// a TOML section on a wrapper config), the policy is preserved.
721    #[test]
722    fn ws_endpoint_policy_from_toml_preserved_across_from_uri() {
723        // Simulate TOML [reconnect_policy] section deserialized into a
724        // NetworkRetryPolicy.
725        let toml_policy = NetworkRetryPolicy {
726            enabled: true,
727            max_attempts: 10,
728            initial_delay: std::time::Duration::from_millis(250),
729            multiplier: 3.0,
730            max_delay: std::time::Duration::from_secs(60),
731            jitter_factor: 0.1,
732            max_attempts_absolute: None,
733        };
734        // This mimics how a WsConfig (or future wrapper) would layer the
735        // TOML policy onto the endpoint config before create_endpoint().
736        // The critical assertion: from_uri() with no flat reconnect params
737        // must not clobber the custom policy.
738        let mut cfg = WsEndpointConfig::from_uri("ws://localhost:8080/echo").unwrap();
739        cfg.reconnect_policy = toml_policy.clone();
740        // Re-derive from_uri to simulate endpoint creation path: from_uri()
741        // always starts from WsEndpointConfig::default().
742        let cfg2 = WsEndpointConfig::from_uri("ws://localhost:8080/echo").unwrap();
743        // After from_uri(), the policy should be at WsEndpointConfig defaults
744        // (since from_uri creates fresh from WsEndpointConfig::default()).
745        // The key guard: when no flat reconnect fields are present, the bridge
746        // does not change the policy at all.
747        let default_policy = WsEndpointConfig::default().reconnect_policy;
748        assert_eq!(cfg2.reconnect_policy, default_policy);
749        // But if we manually set the policy AFTER from_uri (as caller code
750        // does), it stays:
751        cfg.reconnect_policy = toml_policy.clone();
752        assert_eq!(cfg.reconnect_policy.max_attempts, 10);
753        assert_eq!(
754            cfg.reconnect_policy.initial_delay,
755            std::time::Duration::from_millis(250)
756        );
757    }
758
759    /// When SOME but not ALL flat reconnect fields are provided, only the
760    /// explicit ones bridge; non‑explicit fields keep the default policy value.
761    #[test]
762    fn ws_endpoint_policy_partial_bridge() {
763        let uri = "ws://localhost:9001/test?reconnectMaxAttempts=10";
764        let cfg = WsEndpointConfig::from_uri(uri).unwrap();
765        // max_attempts bridged from URI
766        assert_eq!(cfg.reconnect_policy.max_attempts, 10);
767        // enabled and initial_delay remain at defaults (no explicit URI param)
768        assert!(cfg.reconnect_policy.enabled);
769        assert_eq!(
770            cfg.reconnect_policy.initial_delay,
771            std::time::Duration::from_millis(1000)
772        );
773    }
774
775    /// TOML `[reconnect_policy]` parses correctly into `NetworkRetryPolicy`.
776    /// This verifies the deserialization path that future WS configs will use.
777    #[test]
778    fn network_retry_policy_from_toml() {
779        let toml_str = r#"
780            enabled = true
781            max_attempts = 10
782            initial_delay_ms = 250
783            multiplier = 3.0
784            max_delay_ms = 60000
785            jitter_factor = 0.1
786        "#;
787        let policy: NetworkRetryPolicy = toml::from_str(toml_str).unwrap();
788        assert!(policy.enabled);
789        assert_eq!(policy.max_attempts, 10);
790        assert_eq!(policy.initial_delay, std::time::Duration::from_millis(250));
791        assert!((policy.multiplier - 3.0).abs() < f64::EPSILON);
792        assert_eq!(policy.max_delay, std::time::Duration::from_millis(60_000));
793        assert!((policy.jitter_factor - 0.1).abs() < f64::EPSILON);
794    }
795}
796
797#[cfg(test)]
798mod uri_parity_tests {
799    #[test]
800    fn uri_options_count_parity() {
801        assert_eq!(
802            super::WsUriConfig::uri_options().len(),
803            16,
804            "WsUriConfig #[uri_param] count drifted from parser"
805        );
806    }
807}