Skip to main content

geode_client/
dsn.rs

1//! DSN (Data Source Name) parsing for Geode connections.
2//!
3//! See geode/docs/DSN.md for the full specification.
4//!
5//! Supports the following DSN formats:
6//! - `quic://host:port?options` - QUIC transport (recommended)
7//! - `grpc://host:port?options` - gRPC transport
8//! - `host:port?options` - Defaults to QUIC
9//!
10//! # Examples
11//!
12//! ```
13//! use geode_client::dsn::{Dsn, Transport};
14//!
15//! // QUIC transport (explicit)
16//! let dsn = Dsn::parse("quic://localhost:3141").unwrap();
17//! assert_eq!(dsn.transport(), Transport::Quic);
18//!
19//! // gRPC transport
20//! let dsn = Dsn::parse("grpc://localhost:50051?tls=0").unwrap();
21//! assert_eq!(dsn.transport(), Transport::Grpc);
22//! assert!(!dsn.tls_enabled());
23//!
24//! // IPv6 support
25//! let dsn = Dsn::parse("grpc://[::1]:50051").unwrap();
26//! assert_eq!(dsn.host(), "::1");
27//! ```
28
29use crate::error::{Error, Result};
30use std::collections::HashMap;
31
32/// Default port for Geode connections
33pub const DEFAULT_PORT: u16 = 3141;
34
35/// Transport protocol to use for the connection.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub enum Transport {
38    /// QUIC transport with protobuf wire protocol
39    Quic,
40    /// gRPC transport using protobuf service definitions
41    Grpc,
42}
43
44impl std::fmt::Display for Transport {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match self {
47            Transport::Quic => write!(f, "quic"),
48            Transport::Grpc => write!(f, "grpc"),
49        }
50    }
51}
52
53/// Parsed DSN (Data Source Name) for Geode connections.
54///
55/// Contains all connection parameters extracted from a DSN string.
56#[derive(Debug, Clone)]
57pub struct Dsn {
58    transport: Transport,
59    host: String,
60    port: u16,
61    username: Option<String>,
62    password: Option<String>,
63    tls_enabled: bool,
64    skip_verify: bool,
65    page_size: usize,
66    client_name: String,
67    client_version: String,
68    conformance: String,
69    ca_cert: Option<String>,
70    client_cert: Option<String>,
71    client_key: Option<String>,
72    server_name: Option<String>,
73    connect_timeout_secs: Option<u64>,
74    options: HashMap<String, String>,
75}
76
77impl Default for Dsn {
78    fn default() -> Self {
79        Self {
80            transport: Transport::Quic,
81            host: "localhost".to_string(),
82            port: DEFAULT_PORT,
83            username: None,
84            password: None,
85            tls_enabled: true,
86            skip_verify: false,
87            page_size: 1000,
88            client_name: "geode-rust".to_string(),
89            client_version: env!("CARGO_PKG_VERSION").to_string(),
90            conformance: "min".to_string(),
91            ca_cert: None,
92            client_cert: None,
93            client_key: None,
94            server_name: None,
95            connect_timeout_secs: None,
96            options: HashMap::new(),
97        }
98    }
99}
100
101impl Dsn {
102    /// Parse a DSN string into a Dsn struct.
103    ///
104    /// # Supported formats
105    ///
106    /// - `quic://host:port?options` - QUIC transport (recommended)
107    /// - `grpc://host:port?options` - gRPC transport
108    /// - `host:port?options` - Defaults to QUIC
109    ///
110    /// # Supported options (query parameters)
111    ///
112    /// - `tls` - Enable/disable TLS (0/1/true/false, default: true)
113    /// - `insecure`, `skip_verify`, `insecure_skip_verify` - Skip TLS verification
114    /// - `page_size` - Results page size (default: 1000)
115    /// - `client_name` or `hello_name` - Client name
116    /// - `client_version` or `hello_ver` - Client version
117    /// - `conformance` - GQL conformance level
118    /// - `username` or `user` - Authentication username
119    /// - `password` or `pass` - Authentication password
120    /// - `ca` or `ca_cert` - Path to CA certificate
121    /// - `cert` or `client_cert` - Path to client certificate (mTLS)
122    /// - `key` or `client_key` - Path to client key (mTLS)
123    /// - `server_name` - SNI server name
124    /// - `connect_timeout` or `timeout` - Connection timeout in seconds
125    ///
126    /// # Errors
127    ///
128    /// Returns `Error::InvalidDsn` if:
129    /// - DSN is empty
130    /// - Scheme is unsupported
131    /// - Host is missing
132    /// - Port is invalid
133    ///
134    /// # Examples
135    ///
136    /// ```
137    /// use geode_client::dsn::Dsn;
138    ///
139    /// let dsn = Dsn::parse("quic://localhost:3141").unwrap();
140    /// let dsn = Dsn::parse("grpc://127.0.0.1:50051?tls=0").unwrap();
141    /// let dsn = Dsn::parse("grpc://[::1]:50051").unwrap();
142    /// ```
143    pub fn parse(dsn: &str) -> Result<Self> {
144        let dsn = dsn.trim();
145        if dsn.is_empty() {
146            return Err(Error::invalid_dsn("DSN cannot be empty"));
147        }
148
149        // Determine scheme and parse accordingly
150        if dsn.starts_with("quic://") {
151            Self::parse_url(dsn, Transport::Quic)
152        } else if dsn.starts_with("grpc://") {
153            Self::parse_url(dsn, Transport::Grpc)
154        } else if dsn.contains("://") {
155            // Unsupported scheme - reject it
156            let scheme = dsn.split("://").next().unwrap_or("");
157            Err(Error::invalid_dsn(format!(
158                "Unsupported scheme '{}'. Supported schemes: quic://, grpc://",
159                scheme
160            )))
161        } else {
162            // Scheme-less format: host:port?options (defaults to QUIC)
163            Self::parse_legacy(dsn)
164        }
165    }
166
167    /// Parse URL-format DSN (quic://, grpc://)
168    fn parse_url(dsn: &str, transport: Transport) -> Result<Self> {
169        // Use url crate for proper parsing
170        let url = url::Url::parse(dsn)
171            .map_err(|e| Error::invalid_dsn(format!("Invalid URL format: {}", e)))?;
172
173        let host_raw = url
174            .host_str()
175            .ok_or_else(|| Error::invalid_dsn("Host is required"))?;
176
177        // Strip brackets from IPv6 addresses (url crate returns "[::1]" for IPv6)
178        let host = if host_raw.starts_with('[') && host_raw.ends_with(']') {
179            host_raw[1..host_raw.len() - 1].to_string()
180        } else {
181            host_raw.to_string()
182        };
183
184        if host.is_empty() {
185            return Err(Error::invalid_dsn("Host is required"));
186        }
187
188        let port = url.port().unwrap_or(DEFAULT_PORT);
189
190        // Extract username/password with percent-decoding
191        let username = if !url.username().is_empty() {
192            Some(
193                urlencoding::decode(url.username())
194                    .map_err(|e| Error::invalid_dsn(format!("Invalid username encoding: {}", e)))?
195                    .into_owned(),
196            )
197        } else {
198            None
199        };
200
201        let password = url.password().map(|p| {
202            urlencoding::decode(p)
203                .map(|s| s.into_owned())
204                .unwrap_or_else(|_| p.to_string())
205        });
206
207        // Parse query parameters
208        let params: HashMap<String, String> = url.query_pairs().into_owned().collect();
209
210        let mut result = Self {
211            transport,
212            host,
213            port,
214            username,
215            password,
216            ..Default::default()
217        };
218
219        result.apply_params(&params)?;
220
221        Ok(result)
222    }
223
224    /// Parse legacy format DSN (host:port?options)
225    fn parse_legacy(dsn: &str) -> Result<Self> {
226        // Split off query string
227        let (host_port, query_str) = if let Some(idx) = dsn.find('?') {
228            (&dsn[..idx], Some(&dsn[idx + 1..]))
229        } else {
230            (dsn, None)
231        };
232
233        // Parse host:port, handling IPv6 addresses like [::1]:3141
234        let (host, port) = Self::parse_host_port(host_port)?;
235
236        if host.is_empty() {
237            return Err(Error::invalid_dsn("Host is required"));
238        }
239
240        let mut result = Self {
241            transport: Transport::Quic, // Default to QUIC for legacy format
242            host,
243            port,
244            ..Default::default()
245        };
246
247        // Parse query parameters
248        if let Some(qs) = query_str {
249            let params: HashMap<String, String> = qs
250                .split('&')
251                .filter_map(|pair| {
252                    let mut parts = pair.splitn(2, '=');
253                    let key = parts.next()?;
254                    let value = parts.next().unwrap_or("");
255                    // Percent-decode values
256                    let decoded_value = urlencoding::decode(value)
257                        .map(|s| s.into_owned())
258                        .unwrap_or_else(|_| value.to_string());
259                    Some((key.to_string(), decoded_value))
260                })
261                .collect();
262
263            result.apply_params(&params)?;
264        }
265
266        Ok(result)
267    }
268
269    /// Parse host:port string, handling IPv6 addresses
270    fn parse_host_port(s: &str) -> Result<(String, u16)> {
271        // Handle IPv6 addresses: [::1]:3141 or [2001:db8::1]:3141
272        if s.starts_with('[') {
273            // IPv6 format
274            if let Some(bracket_end) = s.find(']') {
275                let host = s[1..bracket_end].to_string();
276                let remainder = &s[bracket_end + 1..];
277                let port = if let Some(rest) = remainder.strip_prefix(':') {
278                    rest.parse::<u16>()
279                        .map_err(|_| Error::invalid_dsn(format!("Invalid port: {}", rest)))?
280                } else if remainder.is_empty() {
281                    DEFAULT_PORT
282                } else {
283                    return Err(Error::invalid_dsn("Invalid IPv6 address format"));
284                };
285                return Ok((host, port));
286            } else {
287                return Err(Error::invalid_dsn("Unclosed bracket in IPv6 address"));
288            }
289        }
290
291        // IPv4 or hostname: find the last colon for port
292        if let Some(idx) = s.rfind(':') {
293            let host = s[..idx].to_string();
294            let port_str = &s[idx + 1..];
295            let port = port_str
296                .parse::<u16>()
297                .map_err(|_| Error::invalid_dsn(format!("Invalid port: {}", port_str)))?;
298            Ok((host, port))
299        } else {
300            // No port specified
301            Ok((s.to_string(), DEFAULT_PORT))
302        }
303    }
304
305    /// Apply query parameters to the DSN
306    fn apply_params(&mut self, params: &HashMap<String, String>) -> Result<()> {
307        for (key, value) in params {
308            match key.as_str() {
309                "tls" => {
310                    self.tls_enabled = parse_bool(value).unwrap_or(true);
311                }
312                "insecure_tls_skip_verify" | "insecure" | "skip_verify" => {
313                    self.skip_verify = parse_bool(value).unwrap_or(false);
314                }
315                "page_size" => {
316                    self.page_size = value
317                        .parse()
318                        .map_err(|_| Error::invalid_dsn(format!("Invalid page_size: {}", value)))?;
319                }
320                "client_name" | "hello_name" => {
321                    self.client_name = value.clone();
322                }
323                "client_version" | "hello_ver" => {
324                    self.client_version = value.clone();
325                }
326                "conformance" => {
327                    self.conformance = value.clone();
328                }
329                "username" | "user" => {
330                    self.username = Some(value.clone());
331                }
332                "password" | "pass" => {
333                    self.password = Some(value.clone());
334                }
335                "ca" | "ca_cert" => {
336                    self.ca_cert = Some(value.clone());
337                }
338                "cert" | "client_cert" => {
339                    self.client_cert = Some(value.clone());
340                }
341                "key" | "client_key" => {
342                    self.client_key = Some(value.clone());
343                }
344                "server_name" => {
345                    self.server_name = Some(value.clone());
346                }
347                "connect_timeout" | "timeout" => {
348                    self.connect_timeout_secs = value.parse().ok();
349                }
350                _ => {
351                    // Store unknown parameters for forward compatibility
352                    self.options.insert(key.clone(), value.clone());
353                }
354            }
355        }
356        Ok(())
357    }
358
359    /// Get the transport protocol.
360    pub fn transport(&self) -> Transport {
361        self.transport
362    }
363
364    /// Get the host.
365    pub fn host(&self) -> &str {
366        &self.host
367    }
368
369    /// Get the port.
370    pub fn port(&self) -> u16 {
371        self.port
372    }
373
374    /// Get the username if specified.
375    pub fn username(&self) -> Option<&str> {
376        self.username.as_deref()
377    }
378
379    /// Get the password if specified.
380    pub fn password(&self) -> Option<&str> {
381        self.password.as_deref()
382    }
383
384    /// Check if TLS is enabled.
385    pub fn tls_enabled(&self) -> bool {
386        self.tls_enabled
387    }
388
389    /// Check if TLS verification should be skipped.
390    pub fn skip_verify(&self) -> bool {
391        self.skip_verify
392    }
393
394    /// Get the page size.
395    pub fn page_size(&self) -> usize {
396        self.page_size
397    }
398
399    /// Get the client name.
400    pub fn client_name(&self) -> &str {
401        &self.client_name
402    }
403
404    /// Get the client version.
405    pub fn client_version(&self) -> &str {
406        &self.client_version
407    }
408
409    /// Get the conformance level.
410    pub fn conformance(&self) -> &str {
411        &self.conformance
412    }
413
414    /// Get additional options.
415    pub fn options(&self) -> &HashMap<String, String> {
416        &self.options
417    }
418
419    /// Get the CA certificate path if specified.
420    pub fn ca_cert(&self) -> Option<&str> {
421        self.ca_cert.as_deref()
422    }
423
424    /// Get the client certificate path if specified.
425    pub fn client_cert(&self) -> Option<&str> {
426        self.client_cert.as_deref()
427    }
428
429    /// Get the client key path if specified.
430    pub fn client_key(&self) -> Option<&str> {
431        self.client_key.as_deref()
432    }
433
434    /// Get the SNI server name if specified.
435    pub fn server_name(&self) -> Option<&str> {
436        self.server_name.as_deref()
437    }
438
439    /// Get the connection timeout in seconds if specified.
440    pub fn connect_timeout_secs(&self) -> Option<u64> {
441        self.connect_timeout_secs
442    }
443
444    /// Get the host:port address string.
445    pub fn address(&self) -> String {
446        if self.host.contains(':') {
447            // IPv6 address - wrap in brackets
448            format!("[{}]:{}", self.host, self.port)
449        } else {
450            format!("{}:{}", self.host, self.port)
451        }
452    }
453}
454
455/// Parse a boolean value from a string
456fn parse_bool(s: &str) -> Option<bool> {
457    match s.to_lowercase().as_str() {
458        "true" | "1" | "yes" | "on" => Some(true),
459        "false" | "0" | "no" | "off" => Some(false),
460        _ => None,
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    // ==========================================================================
469    // QUIC DSN Parsing Tests
470    // ==========================================================================
471
472    #[test]
473    fn test_dsn_parse_quic_basic() {
474        let dsn = Dsn::parse("quic://localhost:3141").unwrap();
475        assert_eq!(dsn.transport(), Transport::Quic);
476        assert_eq!(dsn.host(), "localhost");
477        assert_eq!(dsn.port(), 3141);
478        assert!(dsn.tls_enabled());
479        assert!(!dsn.skip_verify());
480    }
481
482    #[test]
483    fn test_dsn_parse_quic_with_ip() {
484        let dsn = Dsn::parse("quic://127.0.0.1:3141").unwrap();
485        assert_eq!(dsn.transport(), Transport::Quic);
486        assert_eq!(dsn.host(), "127.0.0.1");
487        assert_eq!(dsn.port(), 3141);
488    }
489
490    #[test]
491    fn test_dsn_parse_quic_default_port() {
492        let dsn = Dsn::parse("quic://localhost").unwrap();
493        assert_eq!(dsn.port(), DEFAULT_PORT);
494    }
495
496    #[test]
497    fn test_dsn_parse_quic_with_options() {
498        let dsn = Dsn::parse("quic://localhost:3141?page_size=500&insecure=true").unwrap();
499        assert_eq!(dsn.page_size(), 500);
500        assert!(dsn.skip_verify());
501    }
502
503    // ==========================================================================
504    // gRPC DSN Parsing Tests
505    // ==========================================================================
506
507    #[test]
508    fn test_dsn_parse_grpc_basic() {
509        let dsn = Dsn::parse("grpc://localhost:50051").unwrap();
510        assert_eq!(dsn.transport(), Transport::Grpc);
511        assert_eq!(dsn.host(), "localhost");
512        assert_eq!(dsn.port(), 50051);
513    }
514
515    #[test]
516    fn test_dsn_parse_grpc_with_tls_disabled() {
517        let dsn = Dsn::parse("grpc://127.0.0.1:50051?tls=0").unwrap();
518        assert_eq!(dsn.transport(), Transport::Grpc);
519        assert_eq!(dsn.host(), "127.0.0.1");
520        assert_eq!(dsn.port(), 50051);
521        assert!(!dsn.tls_enabled());
522    }
523
524    #[test]
525    fn test_dsn_parse_grpc_with_tls_false() {
526        let dsn = Dsn::parse("grpc://localhost:50051?tls=false").unwrap();
527        assert!(!dsn.tls_enabled());
528    }
529
530    #[test]
531    fn test_dsn_parse_unsupported_schemes() {
532        // grcp:// is no longer supported
533        let err = Dsn::parse("grcp://localhost:50051").unwrap_err();
534        assert!(err.to_string().contains("Unsupported scheme"));
535
536        // geode:// is no longer supported
537        let err = Dsn::parse("geode://localhost:3141").unwrap_err();
538        assert!(err.to_string().contains("Unsupported scheme"));
539    }
540
541    // ==========================================================================
542    // IPv6 DSN Parsing Tests
543    // ==========================================================================
544
545    #[test]
546    fn test_dsn_parse_ipv6_grpc() {
547        let dsn = Dsn::parse("grpc://[::1]:50051").unwrap();
548        assert_eq!(dsn.transport(), Transport::Grpc);
549        assert_eq!(dsn.host(), "::1");
550        assert_eq!(dsn.port(), 50051);
551    }
552
553    #[test]
554    fn test_dsn_parse_ipv6_quic() {
555        let dsn = Dsn::parse("quic://[::1]:3141").unwrap();
556        assert_eq!(dsn.transport(), Transport::Quic);
557        assert_eq!(dsn.host(), "::1");
558        assert_eq!(dsn.port(), 3141);
559    }
560
561    #[test]
562    fn test_dsn_parse_ipv6_full_address() {
563        let dsn = Dsn::parse("grpc://[2001:db8::1]:50051").unwrap();
564        assert_eq!(dsn.host(), "2001:db8::1");
565        assert_eq!(dsn.port(), 50051);
566    }
567
568    #[test]
569    fn test_dsn_parse_ipv6_default_port() {
570        let dsn = Dsn::parse("quic://[::1]").unwrap();
571        assert_eq!(dsn.host(), "::1");
572        assert_eq!(dsn.port(), DEFAULT_PORT);
573    }
574
575    #[test]
576    fn test_dsn_address_ipv6() {
577        let dsn = Dsn::parse("grpc://[::1]:50051").unwrap();
578        assert_eq!(dsn.address(), "[::1]:50051");
579    }
580
581    // ==========================================================================
582    // Legacy DSN Format Tests
583    // ==========================================================================
584
585    #[test]
586    fn test_dsn_parse_legacy_host_port() {
587        let dsn = Dsn::parse("localhost:3141").unwrap();
588        assert_eq!(dsn.transport(), Transport::Quic); // Default to QUIC
589        assert_eq!(dsn.host(), "localhost");
590        assert_eq!(dsn.port(), 3141);
591    }
592
593    #[test]
594    fn test_dsn_parse_legacy_with_options() {
595        let dsn = Dsn::parse("localhost:3141?insecure=true&page_size=500").unwrap();
596        assert_eq!(dsn.transport(), Transport::Quic);
597        assert!(dsn.skip_verify());
598        assert_eq!(dsn.page_size(), 500);
599    }
600
601    #[test]
602    fn test_dsn_parse_legacy_ipv6() {
603        let dsn = Dsn::parse("[::1]:3141").unwrap();
604        assert_eq!(dsn.host(), "::1");
605        assert_eq!(dsn.port(), 3141);
606    }
607
608    // ==========================================================================
609    // Authentication Tests
610    // ==========================================================================
611
612    #[test]
613    fn test_dsn_parse_with_auth() {
614        let dsn = Dsn::parse("quic://admin:secret@localhost:3141").unwrap();
615        assert_eq!(dsn.username(), Some("admin"));
616        assert_eq!(dsn.password(), Some("secret"));
617    }
618
619    #[test]
620    fn test_dsn_parse_auth_via_query_params() {
621        let dsn = Dsn::parse("grpc://localhost:50051?username=admin&password=secret").unwrap();
622        assert_eq!(dsn.username(), Some("admin"));
623        assert_eq!(dsn.password(), Some("secret"));
624    }
625
626    #[test]
627    fn test_dsn_parse_auth_percent_encoded() {
628        let dsn = Dsn::parse("quic://user%40domain:p%40ss%3Dword@localhost:3141").unwrap();
629        assert_eq!(dsn.username(), Some("user@domain"));
630        assert_eq!(dsn.password(), Some("p@ss=word"));
631    }
632
633    // ==========================================================================
634    // Error Cases Tests
635    // ==========================================================================
636
637    #[test]
638    fn test_dsn_parse_empty() {
639        let err = Dsn::parse("").unwrap_err();
640        assert!(err.to_string().contains("empty"));
641    }
642
643    #[test]
644    fn test_dsn_parse_unsupported_scheme() {
645        let err = Dsn::parse("http://localhost:3141").unwrap_err();
646        assert!(err.to_string().contains("Unsupported scheme"));
647        assert!(err.to_string().contains("http"));
648    }
649
650    #[test]
651    fn test_dsn_parse_invalid_port() {
652        let err = Dsn::parse("quic://localhost:invalid").unwrap_err();
653        assert!(err.to_string().contains("port") || err.to_string().contains("Invalid"));
654    }
655
656    #[test]
657    fn test_dsn_parse_port_too_large() {
658        let err = Dsn::parse("quic://localhost:99999").unwrap_err();
659        assert!(err.to_string().contains("port") || err.to_string().contains("Invalid"));
660    }
661
662    #[test]
663    fn test_dsn_parse_missing_host() {
664        // URL crate handles this differently - empty host is valid for some schemes
665        // But we should reject it
666        let result = Dsn::parse("quic://:3141");
667        // This might parse as empty host or fail depending on URL parser
668        if let Ok(dsn) = result {
669            assert!(dsn.host().is_empty() || dsn.host() == "");
670        }
671    }
672
673    // ==========================================================================
674    // Query Parameter Tests
675    // ==========================================================================
676
677    #[test]
678    fn test_dsn_parse_all_options() {
679        let dsn = Dsn::parse(
680            "grpc://localhost:50051?tls=1&insecure=false&page_size=2000&client_name=test-app&conformance=full"
681        ).unwrap();
682
683        assert!(dsn.tls_enabled());
684        assert!(!dsn.skip_verify());
685        assert_eq!(dsn.page_size(), 2000);
686        assert_eq!(dsn.client_name(), "test-app");
687        assert_eq!(dsn.conformance(), "full");
688    }
689
690    #[test]
691    fn test_dsn_parse_unknown_options_preserved() {
692        let dsn = Dsn::parse("quic://localhost:3141?custom_option=value").unwrap();
693        assert_eq!(
694            dsn.options().get("custom_option"),
695            Some(&"value".to_string())
696        );
697    }
698
699    #[test]
700    fn test_dsn_parse_option_aliases() {
701        // Test username/user alias
702        let dsn1 = Dsn::parse("grpc://localhost:50051?user=admin").unwrap();
703        let dsn2 = Dsn::parse("grpc://localhost:50051?username=admin").unwrap();
704        assert_eq!(dsn1.username(), dsn2.username());
705
706        // Test password/pass alias
707        let dsn3 = Dsn::parse("grpc://localhost:50051?pass=secret").unwrap();
708        let dsn4 = Dsn::parse("grpc://localhost:50051?password=secret").unwrap();
709        assert_eq!(dsn3.password(), dsn4.password());
710
711        // Test skip_verify/insecure alias
712        let dsn5 = Dsn::parse("grpc://localhost:50051?skip_verify=true").unwrap();
713        let dsn6 = Dsn::parse("grpc://localhost:50051?insecure=true").unwrap();
714        assert_eq!(dsn5.skip_verify(), dsn6.skip_verify());
715    }
716
717    #[test]
718    fn test_dsn_parse_percent_encoded_values() {
719        let dsn = Dsn::parse("quic://localhost:3141?client_name=My%20App").unwrap();
720        assert_eq!(dsn.client_name(), "My App");
721    }
722
723    #[test]
724    fn test_dsn_parse_mtls_options() {
725        let dsn = Dsn::parse(
726            "grpc://localhost:50051?ca=/path/ca.crt&cert=/path/client.crt&key=/path/client.key",
727        )
728        .unwrap();
729        assert_eq!(dsn.ca_cert(), Some("/path/ca.crt"));
730        assert_eq!(dsn.client_cert(), Some("/path/client.crt"));
731        assert_eq!(dsn.client_key(), Some("/path/client.key"));
732    }
733
734    #[test]
735    fn test_dsn_parse_mtls_options_alt_names() {
736        let dsn = Dsn::parse("grpc://localhost:50051?ca_cert=/path/ca.crt&client_cert=/path/client.crt&client_key=/path/client.key").unwrap();
737        assert_eq!(dsn.ca_cert(), Some("/path/ca.crt"));
738        assert_eq!(dsn.client_cert(), Some("/path/client.crt"));
739        assert_eq!(dsn.client_key(), Some("/path/client.key"));
740    }
741
742    #[test]
743    fn test_dsn_parse_connect_timeout() {
744        let dsn = Dsn::parse("quic://localhost:3141?connect_timeout=60").unwrap();
745        assert_eq!(dsn.connect_timeout_secs(), Some(60));
746    }
747
748    #[test]
749    fn test_dsn_parse_server_name() {
750        let dsn = Dsn::parse("quic://localhost:3141?server_name=geode.example.com").unwrap();
751        assert_eq!(dsn.server_name(), Some("geode.example.com"));
752    }
753
754    // ==========================================================================
755    // insecure_tls_skip_verify Parameter Tests (Unified DSN)
756    // ==========================================================================
757
758    #[test]
759    fn test_dsn_parse_insecure_tls_skip_verify_primary() {
760        // Primary parameter name
761        let dsn = Dsn::parse("quic://localhost:3141?insecure_tls_skip_verify=true").unwrap();
762        assert!(dsn.skip_verify());
763
764        let dsn = Dsn::parse("quic://localhost:3141?insecure_tls_skip_verify=1").unwrap();
765        assert!(dsn.skip_verify());
766
767        let dsn = Dsn::parse("quic://localhost:3141?insecure_tls_skip_verify=yes").unwrap();
768        assert!(dsn.skip_verify());
769
770        let dsn = Dsn::parse("quic://localhost:3141?insecure_tls_skip_verify=on").unwrap();
771        assert!(dsn.skip_verify());
772
773        let dsn = Dsn::parse("quic://localhost:3141?insecure_tls_skip_verify=false").unwrap();
774        assert!(!dsn.skip_verify());
775    }
776
777    #[test]
778    fn test_dsn_parse_insecure_alias() {
779        // Alias: insecure
780        let dsn = Dsn::parse("quic://localhost:3141?insecure=true").unwrap();
781        assert!(dsn.skip_verify());
782
783        let dsn = Dsn::parse("grpc://localhost:50051?insecure=1").unwrap();
784        assert!(dsn.skip_verify());
785
786        let dsn = Dsn::parse("localhost:3141?insecure=yes").unwrap();
787        assert!(dsn.skip_verify());
788    }
789
790    #[test]
791    fn test_dsn_parse_skip_verify_alias() {
792        // Alias: skip_verify
793        let dsn = Dsn::parse("quic://localhost:3141?skip_verify=true").unwrap();
794        assert!(dsn.skip_verify());
795
796        let dsn = Dsn::parse("grpc://localhost:50051?skip_verify=1").unwrap();
797        assert!(dsn.skip_verify());
798
799        let dsn = Dsn::parse("quic://localhost:3141?skip_verify=on").unwrap();
800        assert!(dsn.skip_verify());
801    }
802
803    #[test]
804    fn test_dsn_skip_verify_default_false() {
805        // Default should be false
806        let dsn = Dsn::parse("quic://localhost:3141").unwrap();
807        assert!(!dsn.skip_verify());
808
809        let dsn = Dsn::parse("grpc://localhost:50051").unwrap();
810        assert!(!dsn.skip_verify());
811
812        let dsn = Dsn::parse("localhost:3141").unwrap();
813        assert!(!dsn.skip_verify());
814    }
815
816    // ==========================================================================
817    // Transport Display Tests
818    // ==========================================================================
819
820    #[test]
821    fn test_transport_display() {
822        assert_eq!(Transport::Quic.to_string(), "quic");
823        assert_eq!(Transport::Grpc.to_string(), "grpc");
824    }
825
826    // ==========================================================================
827    // Address Formatting Tests
828    // ==========================================================================
829
830    #[test]
831    fn test_dsn_address_ipv4() {
832        let dsn = Dsn::parse("quic://192.168.1.1:3141").unwrap();
833        assert_eq!(dsn.address(), "192.168.1.1:3141");
834    }
835
836    #[test]
837    fn test_dsn_address_hostname() {
838        let dsn = Dsn::parse("grpc://geode.example.com:50051").unwrap();
839        assert_eq!(dsn.address(), "geode.example.com:50051");
840    }
841
842    // ==========================================================================
843    // Acceptance Tests (from task requirements)
844    // ==========================================================================
845
846    #[test]
847    fn test_acceptance_quic_localhost() {
848        // DSN parse: quic://localhost:1234 ⇒ transport=QUIC, host=localhost, port=1234
849        let dsn = Dsn::parse("quic://localhost:1234").unwrap();
850        assert_eq!(dsn.transport(), Transport::Quic);
851        assert_eq!(dsn.host(), "localhost");
852        assert_eq!(dsn.port(), 1234);
853    }
854
855    #[test]
856    fn test_acceptance_grpc_with_tls_disabled() {
857        // DSN parse: grpc://127.0.0.1:50051?tls=0 ⇒ transport=GRPC, host=127.0.0.1, port=50051, tls disabled
858        let dsn = Dsn::parse("grpc://127.0.0.1:50051?tls=0").unwrap();
859        assert_eq!(dsn.transport(), Transport::Grpc);
860        assert_eq!(dsn.host(), "127.0.0.1");
861        assert_eq!(dsn.port(), 50051);
862        assert!(!dsn.tls_enabled());
863    }
864
865    #[test]
866    fn test_acceptance_invalid_scheme() {
867        // DSN parse invalid: http://localhost:1 ⇒ deterministic "unsupported scheme" error
868        let err = Dsn::parse("http://localhost:1").unwrap_err();
869        let err_str = err.to_string();
870        assert!(
871            err_str.contains("Unsupported scheme") || err_str.contains("unsupported"),
872            "Expected 'unsupported scheme' error, got: {}",
873            err_str
874        );
875    }
876}