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//! - `grpcs://host:port?options` - gRPC transport with TLS forced on
9//! - `host:port?options` - Defaults to QUIC
10//!
11//! # Examples
12//!
13//! ```
14//! use geode_client::dsn::{Dsn, Transport};
15//!
16//! // QUIC transport (explicit)
17//! let dsn = Dsn::parse("quic://localhost:3141").unwrap();
18//! assert_eq!(dsn.transport(), Transport::Quic);
19//!
20//! // gRPC transport
21//! let dsn = Dsn::parse("grpc://localhost:50051?tls=0").unwrap();
22//! assert_eq!(dsn.transport(), Transport::Grpc);
23//! assert!(!dsn.tls_enabled());
24//!
25//! // IPv6 support
26//! let dsn = Dsn::parse("grpc://[::1]:50051").unwrap();
27//! assert_eq!(dsn.host(), "::1");
28//! ```
29
30use crate::error::{Error, Result};
31use std::collections::HashMap;
32
33/// Default port for Geode connections
34pub const DEFAULT_PORT: u16 = 3141;
35
36/// Transport protocol to use for the connection.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub enum Transport {
39    /// QUIC transport with protobuf wire protocol
40    Quic,
41    /// gRPC transport using protobuf service definitions
42    Grpc,
43}
44
45impl std::fmt::Display for Transport {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            Transport::Quic => write!(f, "quic"),
49            Transport::Grpc => write!(f, "grpc"),
50        }
51    }
52}
53
54/// Parsed DSN (Data Source Name) for Geode connections.
55///
56/// Contains all connection parameters extracted from a DSN string.
57#[derive(Clone, PartialEq)]
58pub struct Dsn {
59    transport: Transport,
60    host: String,
61    port: u16,
62    username: Option<String>,
63    password: Option<String>,
64    tls_enabled: bool,
65    skip_verify: bool,
66    page_size: usize,
67    client_name: String,
68    client_version: String,
69    conformance: String,
70    ca_cert: Option<String>,
71    client_cert: Option<String>,
72    client_key: Option<String>,
73    server_name: Option<String>,
74    connect_timeout_secs: Option<u64>,
75    graph: Option<String>,
76    options: HashMap<String, String>,
77}
78
79impl Default for Dsn {
80    fn default() -> Self {
81        Self {
82            transport: Transport::Quic,
83            host: "localhost".to_string(),
84            port: DEFAULT_PORT,
85            username: None,
86            password: None,
87            tls_enabled: true,
88            skip_verify: false,
89            page_size: 1000,
90            client_name: "geode-rust".to_string(),
91            client_version: env!("CARGO_PKG_VERSION").to_string(),
92            conformance: "min".to_string(),
93            ca_cert: None,
94            client_cert: None,
95            client_key: None,
96            server_name: None,
97            connect_timeout_secs: None,
98            graph: None,
99            options: HashMap::new(),
100        }
101    }
102}
103
104impl Dsn {
105    /// Parse a DSN string into a Dsn struct.
106    ///
107    /// # Supported formats
108    ///
109    /// - `quic://host:port?options` - QUIC transport (recommended)
110    /// - `grpc://host:port?options` - gRPC transport
111    /// - `grpcs://host:port?options` - gRPC transport with TLS forced on
112    /// - `host:port?options` - Defaults to QUIC
113    ///
114    /// # Supported options (query parameters)
115    ///
116    /// - `tls` - Enable/disable TLS (0/1/true/false, default: true)
117    /// - `insecure`, `skip_verify`, `insecure_skip_verify` - Skip TLS verification
118    /// - `page_size` - Results page size (default: 1000)
119    /// - `client_name` or `hello_name` - Client name
120    /// - `client_version` or `hello_ver` - Client version
121    /// - `conformance` - GQL conformance level
122    /// - `username` or `user` - Authentication username
123    /// - `password` or `pass` - Authentication password
124    /// - `ca` or `ca_cert` - Path to CA certificate
125    /// - `cert` or `client_cert` - Path to client certificate (mTLS)
126    /// - `key` or `client_key` - Path to client key (mTLS)
127    /// - `server_name` - SNI server name
128    /// - `connect_timeout` or `timeout` - Connection timeout in seconds
129    ///
130    /// # Errors
131    ///
132    /// Returns `Error::InvalidDsn` if:
133    /// - DSN is empty
134    /// - Scheme is unsupported
135    /// - Host is missing
136    /// - Port is invalid
137    ///
138    /// # Examples
139    ///
140    /// ```
141    /// use geode_client::dsn::Dsn;
142    ///
143    /// let dsn = Dsn::parse("quic://localhost:3141").unwrap();
144    /// let dsn = Dsn::parse("grpc://127.0.0.1:50051?tls=0").unwrap();
145    /// let dsn = Dsn::parse("grpc://[::1]:50051").unwrap();
146    /// ```
147    pub fn parse(dsn: &str) -> Result<Self> {
148        let dsn = dsn.trim();
149        if dsn.is_empty() {
150            return Err(Error::invalid_dsn("DSN cannot be empty"));
151        }
152
153        // Determine scheme and parse accordingly
154        if dsn.starts_with("quic://") {
155            Self::parse_url(dsn, Transport::Quic)
156        } else if let Some(rest) = dsn.strip_prefix("grpcs://") {
157            // grpcs:// is gRPC with TLS forced on
158            let rewritten = format!("grpc://{}", rest);
159            let mut result = Self::parse_url(&rewritten, Transport::Grpc)?;
160            result.tls_enabled = true;
161            Ok(result)
162        } else if dsn.starts_with("grpc://") {
163            Self::parse_url(dsn, Transport::Grpc)
164        } else if dsn.contains("://") {
165            // Unsupported scheme - reject it
166            let scheme = dsn.split("://").next().unwrap_or("");
167            Err(Error::invalid_dsn(format!(
168                "Unsupported scheme '{}'. Supported schemes: quic://, grpc://, grpcs://",
169                scheme
170            )))
171        } else {
172            // Scheme-less format: host:port?options (defaults to QUIC)
173            Self::parse_legacy(dsn)
174        }
175    }
176
177    /// Parse URL-format DSN (quic://, grpc://)
178    fn parse_url(dsn: &str, transport: Transport) -> Result<Self> {
179        // Use url crate for proper parsing
180        let url = url::Url::parse(dsn)
181            .map_err(|e| Error::invalid_dsn(format!("Invalid URL format: {}", e)))?;
182
183        let host_raw = url
184            .host_str()
185            .ok_or_else(|| Error::invalid_dsn("Host is required"))?;
186
187        // Strip brackets from IPv6 addresses (url crate returns "[::1]" for IPv6)
188        let host = if host_raw.starts_with('[') && host_raw.ends_with(']') {
189            host_raw[1..host_raw.len() - 1].to_string()
190        } else {
191            host_raw.to_string()
192        };
193
194        if host.is_empty() {
195            return Err(Error::invalid_dsn("Host is required"));
196        }
197
198        let port = url.port().unwrap_or(DEFAULT_PORT);
199
200        // Extract username/password with percent-decoding
201        let username = if !url.username().is_empty() {
202            Some(
203                urlencoding::decode(url.username())
204                    .map_err(|e| Error::invalid_dsn(format!("Invalid username encoding: {}", e)))?
205                    .into_owned(),
206            )
207        } else {
208            None
209        };
210
211        let password = url.password().map(|p| {
212            urlencoding::decode(p)
213                .map(|s| s.into_owned())
214                .unwrap_or_else(|_| p.to_string())
215        });
216
217        // Parse query parameters
218        let params: HashMap<String, String> = url.query_pairs().into_owned().collect();
219
220        // Extract graph from URL path (e.g. quic://host:3141/mygraph)
221        let graph = if !url.path().is_empty() && url.path() != "/" {
222            Some(url.path().trim_start_matches('/').to_string())
223        } else {
224            None
225        };
226
227        let mut result = Self {
228            transport,
229            host,
230            port,
231            username,
232            password,
233            graph,
234            ..Default::default()
235        };
236
237        result.apply_params(&params)?;
238
239        Ok(result)
240    }
241
242    /// Parse legacy format DSN (host:port?options)
243    fn parse_legacy(dsn: &str) -> Result<Self> {
244        // Split off query string
245        let (host_port, query_str) = if let Some(idx) = dsn.find('?') {
246            (&dsn[..idx], Some(&dsn[idx + 1..]))
247        } else {
248            (dsn, None)
249        };
250
251        // Parse host:port, handling IPv6 addresses like [::1]:3141
252        let (host, port) = Self::parse_host_port(host_port)?;
253
254        if host.is_empty() {
255            return Err(Error::invalid_dsn("Host is required"));
256        }
257
258        let mut result = Self {
259            transport: Transport::Quic, // Default to QUIC for legacy format
260            host,
261            port,
262            ..Default::default()
263        };
264
265        // Parse query parameters
266        if let Some(qs) = query_str {
267            let params: HashMap<String, String> = qs
268                .split('&')
269                .filter_map(|pair| {
270                    let mut parts = pair.splitn(2, '=');
271                    let key = parts.next()?;
272                    let value = parts.next().unwrap_or("");
273                    // Percent-decode values
274                    let decoded_value = urlencoding::decode(value)
275                        .map(|s| s.into_owned())
276                        .unwrap_or_else(|_| value.to_string());
277                    Some((key.to_string(), decoded_value))
278                })
279                .collect();
280
281            result.apply_params(&params)?;
282        }
283
284        Ok(result)
285    }
286
287    /// Parse host:port string, handling IPv6 addresses
288    fn parse_host_port(s: &str) -> Result<(String, u16)> {
289        // Handle IPv6 addresses: [::1]:3141 or [2001:db8::1]:3141
290        if s.starts_with('[') {
291            // IPv6 format
292            if let Some(bracket_end) = s.find(']') {
293                let host = s[1..bracket_end].to_string();
294                let remainder = &s[bracket_end + 1..];
295                let port = if let Some(rest) = remainder.strip_prefix(':') {
296                    rest.parse::<u16>()
297                        .map_err(|_| Error::invalid_dsn(format!("Invalid port: {}", rest)))?
298                } else if remainder.is_empty() {
299                    DEFAULT_PORT
300                } else {
301                    return Err(Error::invalid_dsn("Invalid IPv6 address format"));
302                };
303                return Ok((host, port));
304            } else {
305                return Err(Error::invalid_dsn("Unclosed bracket in IPv6 address"));
306            }
307        }
308
309        // IPv4 or hostname: find the last colon for port
310        if let Some(idx) = s.rfind(':') {
311            let host = s[..idx].to_string();
312            let port_str = &s[idx + 1..];
313            let port = port_str
314                .parse::<u16>()
315                .map_err(|_| Error::invalid_dsn(format!("Invalid port: {}", port_str)))?;
316            Ok((host, port))
317        } else {
318            // No port specified
319            Ok((s.to_string(), DEFAULT_PORT))
320        }
321    }
322
323    /// Apply query parameters to the DSN
324    fn apply_params(&mut self, params: &HashMap<String, String>) -> Result<()> {
325        for (key, value) in params {
326            match key.as_str() {
327                "tls" => {
328                    self.tls_enabled = parse_bool(value).unwrap_or(true);
329                }
330                "insecure_tls_skip_verify"
331                | "insecure"
332                | "skip_verify"
333                | "insecure_skip_verify" => {
334                    self.skip_verify = parse_bool(value).unwrap_or(false);
335                }
336                "page_size" => {
337                    self.page_size = value
338                        .parse()
339                        .map_err(|_| Error::invalid_dsn(format!("Invalid page_size: {}", value)))?;
340                }
341                "client_name" | "hello_name" => {
342                    self.client_name = value.clone();
343                }
344                "client_version" | "hello_ver" => {
345                    self.client_version = value.clone();
346                }
347                "conformance" => {
348                    self.conformance = value.clone();
349                }
350                "username" | "user" => {
351                    self.username = Some(value.clone());
352                }
353                "password" | "pass" => {
354                    self.password = Some(value.clone());
355                }
356                "ca" | "ca_cert" => {
357                    self.ca_cert = Some(value.clone());
358                }
359                "cert" | "client_cert" => {
360                    self.client_cert = Some(value.clone());
361                }
362                "key" | "client_key" => {
363                    self.client_key = Some(value.clone());
364                }
365                "server_name" => {
366                    self.server_name = Some(value.clone());
367                }
368                "connect_timeout" | "timeout" => {
369                    self.connect_timeout_secs = value.parse().ok();
370                }
371                "graph" => {
372                    self.graph = Some(value.to_string());
373                }
374                _ => {
375                    // Store unknown parameters for forward compatibility
376                    self.options.insert(key.clone(), value.clone());
377                }
378            }
379        }
380        Ok(())
381    }
382
383    /// Get the transport protocol.
384    pub fn transport(&self) -> Transport {
385        self.transport
386    }
387
388    /// Get the host.
389    pub fn host(&self) -> &str {
390        &self.host
391    }
392
393    /// Get the port.
394    pub fn port(&self) -> u16 {
395        self.port
396    }
397
398    /// Get the username if specified.
399    pub fn username(&self) -> Option<&str> {
400        self.username.as_deref()
401    }
402
403    /// Get the password if specified.
404    pub fn password(&self) -> Option<&str> {
405        self.password.as_deref()
406    }
407
408    /// Check if TLS is enabled.
409    pub fn tls_enabled(&self) -> bool {
410        self.tls_enabled
411    }
412
413    /// Check if TLS verification should be skipped.
414    pub fn skip_verify(&self) -> bool {
415        self.skip_verify
416    }
417
418    /// Get the page size.
419    pub fn page_size(&self) -> usize {
420        self.page_size
421    }
422
423    /// Get the client name.
424    pub fn client_name(&self) -> &str {
425        &self.client_name
426    }
427
428    /// Get the client version.
429    pub fn client_version(&self) -> &str {
430        &self.client_version
431    }
432
433    /// Get the conformance level.
434    pub fn conformance(&self) -> &str {
435        &self.conformance
436    }
437
438    /// Get additional options.
439    pub fn options(&self) -> &HashMap<String, String> {
440        &self.options
441    }
442
443    /// Get the CA certificate path if specified.
444    pub fn ca_cert(&self) -> Option<&str> {
445        self.ca_cert.as_deref()
446    }
447
448    /// Get the client certificate path if specified.
449    pub fn client_cert(&self) -> Option<&str> {
450        self.client_cert.as_deref()
451    }
452
453    /// Get the client key path if specified.
454    pub fn client_key(&self) -> Option<&str> {
455        self.client_key.as_deref()
456    }
457
458    /// Get the SNI server name if specified.
459    pub fn server_name(&self) -> Option<&str> {
460        self.server_name.as_deref()
461    }
462
463    /// Get the connection timeout in seconds if specified.
464    pub fn connect_timeout_secs(&self) -> Option<u64> {
465        self.connect_timeout_secs
466    }
467
468    /// Get the graph name if specified.
469    pub fn graph(&self) -> Option<&str> {
470        self.graph.as_deref()
471    }
472
473    /// Get the host:port address string.
474    pub fn address(&self) -> String {
475        if self.host.contains(':') {
476            // IPv6 address - wrap in brackets
477            format!("[{}]:{}", self.host, self.port)
478        } else {
479            format!("{}:{}", self.host, self.port)
480        }
481    }
482
483    /// Format this DSN back into a connection-string form.
484    ///
485    /// Mirrors the Go reference client's `Config.FormatDSN`: emits
486    /// `scheme://userinfo@host:port?onlyNonDefaultParams`. Only parameters
487    /// that differ from the defaults are emitted, query keys are sorted for
488    /// determinism, and userinfo/values are percent-encoded.
489    ///
490    /// Credentials follow Go's rules: a username with a password becomes
491    /// `user:pass@`, a username alone becomes `user@`, and a password
492    /// without a username falls back to a `pass=` query parameter (since
493    /// userinfo cannot carry a password-only credential).
494    ///
495    /// The result round-trips through [`Dsn::parse`].
496    ///
497    /// # Example
498    ///
499    /// ```
500    /// use geode_client::dsn::Dsn;
501    ///
502    /// let dsn = Dsn::parse("quic://localhost:3141?page_size=500").unwrap();
503    /// let s = dsn.to_dsn();
504    /// assert_eq!(Dsn::parse(&s).unwrap(), dsn);
505    /// ```
506    pub fn to_dsn(&self) -> String {
507        let defaults = Dsn::default();
508
509        // Collect non-default query parameters as (key, value) pairs.
510        let mut params: Vec<(String, String)> = Vec::new();
511
512        if self.page_size != defaults.page_size {
513            params.push(("page_size".to_string(), self.page_size.to_string()));
514        }
515        if self.client_name != defaults.client_name {
516            params.push(("hello_name".to_string(), self.client_name.clone()));
517        }
518        if self.client_version != defaults.client_version {
519            params.push(("hello_ver".to_string(), self.client_version.clone()));
520        }
521        if self.conformance != defaults.conformance {
522            params.push(("conformance".to_string(), self.conformance.clone()));
523        }
524        if let Some(ca) = &self.ca_cert {
525            params.push(("ca".to_string(), ca.clone()));
526        }
527        if let Some(cert) = &self.client_cert {
528            params.push(("cert".to_string(), cert.clone()));
529        }
530        if let Some(key) = &self.client_key {
531            params.push(("key".to_string(), key.clone()));
532        }
533        if let Some(sn) = &self.server_name {
534            params.push(("server_name".to_string(), sn.clone()));
535        }
536        if self.skip_verify {
537            params.push(("insecure_tls_skip_verify".to_string(), "true".to_string()));
538        }
539        if !self.tls_enabled {
540            params.push(("tls".to_string(), "0".to_string()));
541        }
542        if let Some(graph) = &self.graph {
543            params.push(("graph".to_string(), graph.clone()));
544        }
545        if let Some(timeout) = self.connect_timeout_secs {
546            params.push(("connect_timeout".to_string(), timeout.to_string()));
547        }
548        // Preserve forward-compatible unknown options.
549        for (key, value) in &self.options {
550            params.push((key.clone(), value.clone()));
551        }
552
553        // Build userinfo, falling back to a pass= query param when only a
554        // password is present (userinfo requires a username).
555        let userinfo = match (&self.username, &self.password) {
556            (Some(user), Some(pass)) => format!(
557                "{}:{}@",
558                urlencoding::encode(user),
559                urlencoding::encode(pass)
560            ),
561            (Some(user), None) => format!("{}@", urlencoding::encode(user)),
562            (None, Some(pass)) => {
563                params.push(("pass".to_string(), pass.clone()));
564                String::new()
565            }
566            (None, None) => String::new(),
567        };
568
569        // Sort query keys for deterministic output (matches Go's url.Values).
570        params.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
571
572        let mut dsn = format!("{}://{}{}", self.transport, userinfo, self.address());
573
574        if !params.is_empty() {
575            let query: Vec<String> = params
576                .iter()
577                .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
578                .collect();
579            dsn.push('?');
580            dsn.push_str(&query.join("&"));
581        }
582
583        dsn
584    }
585}
586
587impl std::fmt::Display for Dsn {
588    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
589        f.write_str(&self.to_dsn())
590    }
591}
592
593impl std::fmt::Debug for Dsn {
594    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
595        f.debug_struct("Dsn")
596            .field("transport", &self.transport)
597            .field("host", &self.host)
598            .field("port", &self.port)
599            .field("username", &self.username)
600            .field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
601            .field("tls_enabled", &self.tls_enabled)
602            .field("skip_verify", &self.skip_verify)
603            .field("page_size", &self.page_size)
604            .field("client_name", &self.client_name)
605            .field("client_version", &self.client_version)
606            .field("conformance", &self.conformance)
607            .field("ca_cert", &self.ca_cert)
608            .field("client_cert", &self.client_cert)
609            .field("client_key", &self.client_key)
610            .field("server_name", &self.server_name)
611            .field("connect_timeout_secs", &self.connect_timeout_secs)
612            .field("graph", &self.graph)
613            .field("options", &self.options)
614            .finish()
615    }
616}
617
618/// Parse a boolean value from a string
619fn parse_bool(s: &str) -> Option<bool> {
620    match s.to_lowercase().as_str() {
621        "true" | "1" | "yes" | "on" => Some(true),
622        "false" | "0" | "no" | "off" => Some(false),
623        _ => None,
624    }
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630
631    // ==========================================================================
632    // QUIC DSN Parsing Tests
633    // ==========================================================================
634
635    #[test]
636    fn test_dsn_parse_quic_basic() {
637        let dsn = Dsn::parse("quic://localhost:3141").unwrap();
638        assert_eq!(dsn.transport(), Transport::Quic);
639        assert_eq!(dsn.host(), "localhost");
640        assert_eq!(dsn.port(), 3141);
641        assert!(dsn.tls_enabled());
642        assert!(!dsn.skip_verify());
643    }
644
645    #[test]
646    fn test_dsn_parse_quic_with_ip() {
647        let dsn = Dsn::parse("quic://127.0.0.1:3141").unwrap();
648        assert_eq!(dsn.transport(), Transport::Quic);
649        assert_eq!(dsn.host(), "127.0.0.1");
650        assert_eq!(dsn.port(), 3141);
651    }
652
653    #[test]
654    fn test_dsn_parse_quic_default_port() {
655        let dsn = Dsn::parse("quic://localhost").unwrap();
656        assert_eq!(dsn.port(), DEFAULT_PORT);
657    }
658
659    #[test]
660    fn test_dsn_parse_quic_with_options() {
661        let dsn = Dsn::parse("quic://localhost:3141?page_size=500&insecure=true").unwrap();
662        assert_eq!(dsn.page_size(), 500);
663        assert!(dsn.skip_verify());
664    }
665
666    // ==========================================================================
667    // gRPC DSN Parsing Tests
668    // ==========================================================================
669
670    #[test]
671    fn test_dsn_parse_grpc_basic() {
672        let dsn = Dsn::parse("grpc://localhost:50051").unwrap();
673        assert_eq!(dsn.transport(), Transport::Grpc);
674        assert_eq!(dsn.host(), "localhost");
675        assert_eq!(dsn.port(), 50051);
676    }
677
678    #[test]
679    fn test_dsn_parse_grpc_with_tls_disabled() {
680        let dsn = Dsn::parse("grpc://127.0.0.1:50051?tls=0").unwrap();
681        assert_eq!(dsn.transport(), Transport::Grpc);
682        assert_eq!(dsn.host(), "127.0.0.1");
683        assert_eq!(dsn.port(), 50051);
684        assert!(!dsn.tls_enabled());
685    }
686
687    #[test]
688    fn test_dsn_parse_grpc_with_tls_false() {
689        let dsn = Dsn::parse("grpc://localhost:50051?tls=false").unwrap();
690        assert!(!dsn.tls_enabled());
691    }
692
693    #[test]
694    fn test_dsn_parse_grpcs_scheme() {
695        let dsn = Dsn::parse("grpcs://localhost:50051").unwrap();
696        assert_eq!(dsn.transport(), Transport::Grpc);
697        assert!(dsn.tls_enabled());
698        assert_eq!(dsn.host(), "localhost");
699        assert_eq!(dsn.port(), 50051);
700    }
701
702    #[test]
703    fn test_dsn_parse_grpcs_tls_forced() {
704        // Even if tls=0 is passed, grpcs:// forces TLS on
705        let dsn = Dsn::parse("grpcs://localhost:50051?tls=0").unwrap();
706        assert_eq!(dsn.transport(), Transport::Grpc);
707        assert!(dsn.tls_enabled());
708    }
709
710    #[test]
711    fn test_dsn_parse_unsupported_schemes() {
712        // grcp:// is no longer supported
713        let err = Dsn::parse("grcp://localhost:50051").unwrap_err();
714        assert!(err.to_string().contains("Unsupported scheme"));
715
716        // geode:// is no longer supported
717        let err = Dsn::parse("geode://localhost:3141").unwrap_err();
718        assert!(err.to_string().contains("Unsupported scheme"));
719    }
720
721    // ==========================================================================
722    // IPv6 DSN Parsing Tests
723    // ==========================================================================
724
725    #[test]
726    fn test_dsn_parse_ipv6_grpc() {
727        let dsn = Dsn::parse("grpc://[::1]:50051").unwrap();
728        assert_eq!(dsn.transport(), Transport::Grpc);
729        assert_eq!(dsn.host(), "::1");
730        assert_eq!(dsn.port(), 50051);
731    }
732
733    #[test]
734    fn test_dsn_parse_ipv6_quic() {
735        let dsn = Dsn::parse("quic://[::1]:3141").unwrap();
736        assert_eq!(dsn.transport(), Transport::Quic);
737        assert_eq!(dsn.host(), "::1");
738        assert_eq!(dsn.port(), 3141);
739    }
740
741    #[test]
742    fn test_dsn_parse_ipv6_full_address() {
743        let dsn = Dsn::parse("grpc://[2001:db8::1]:50051").unwrap();
744        assert_eq!(dsn.host(), "2001:db8::1");
745        assert_eq!(dsn.port(), 50051);
746    }
747
748    #[test]
749    fn test_dsn_parse_ipv6_default_port() {
750        let dsn = Dsn::parse("quic://[::1]").unwrap();
751        assert_eq!(dsn.host(), "::1");
752        assert_eq!(dsn.port(), DEFAULT_PORT);
753    }
754
755    #[test]
756    fn test_dsn_address_ipv6() {
757        let dsn = Dsn::parse("grpc://[::1]:50051").unwrap();
758        assert_eq!(dsn.address(), "[::1]:50051");
759    }
760
761    // ==========================================================================
762    // Legacy DSN Format Tests
763    // ==========================================================================
764
765    #[test]
766    fn test_dsn_parse_legacy_host_port() {
767        let dsn = Dsn::parse("localhost:3141").unwrap();
768        assert_eq!(dsn.transport(), Transport::Quic); // Default to QUIC
769        assert_eq!(dsn.host(), "localhost");
770        assert_eq!(dsn.port(), 3141);
771    }
772
773    #[test]
774    fn test_dsn_parse_legacy_with_options() {
775        let dsn = Dsn::parse("localhost:3141?insecure=true&page_size=500").unwrap();
776        assert_eq!(dsn.transport(), Transport::Quic);
777        assert!(dsn.skip_verify());
778        assert_eq!(dsn.page_size(), 500);
779    }
780
781    #[test]
782    fn test_dsn_parse_legacy_ipv6() {
783        let dsn = Dsn::parse("[::1]:3141").unwrap();
784        assert_eq!(dsn.host(), "::1");
785        assert_eq!(dsn.port(), 3141);
786    }
787
788    // ==========================================================================
789    // Authentication Tests
790    // ==========================================================================
791
792    #[test]
793    fn test_dsn_parse_with_auth() {
794        let dsn = Dsn::parse("quic://admin:secret@localhost:3141").unwrap();
795        assert_eq!(dsn.username(), Some("admin"));
796        assert_eq!(dsn.password(), Some("secret"));
797    }
798
799    #[test]
800    fn test_dsn_parse_auth_via_query_params() {
801        let dsn = Dsn::parse("grpc://localhost:50051?username=admin&password=secret").unwrap();
802        assert_eq!(dsn.username(), Some("admin"));
803        assert_eq!(dsn.password(), Some("secret"));
804    }
805
806    #[test]
807    fn test_dsn_parse_auth_percent_encoded() {
808        let dsn = Dsn::parse("quic://user%40domain:p%40ss%3Dword@localhost:3141").unwrap();
809        assert_eq!(dsn.username(), Some("user@domain"));
810        assert_eq!(dsn.password(), Some("p@ss=word"));
811    }
812
813    // ==========================================================================
814    // Error Cases Tests
815    // ==========================================================================
816
817    #[test]
818    fn test_dsn_parse_empty() {
819        let err = Dsn::parse("").unwrap_err();
820        assert!(err.to_string().contains("empty"));
821    }
822
823    #[test]
824    fn test_dsn_parse_unsupported_scheme() {
825        let err = Dsn::parse("http://localhost:3141").unwrap_err();
826        assert!(err.to_string().contains("Unsupported scheme"));
827        assert!(err.to_string().contains("http"));
828    }
829
830    #[test]
831    fn test_dsn_parse_invalid_port() {
832        let err = Dsn::parse("quic://localhost:invalid").unwrap_err();
833        assert!(err.to_string().contains("port") || err.to_string().contains("Invalid"));
834    }
835
836    #[test]
837    fn test_dsn_parse_port_too_large() {
838        let err = Dsn::parse("quic://localhost:99999").unwrap_err();
839        assert!(err.to_string().contains("port") || err.to_string().contains("Invalid"));
840    }
841
842    #[test]
843    fn test_dsn_parse_missing_host() {
844        // URL crate handles this differently - empty host is valid for some schemes
845        // But we should reject it
846        let result = Dsn::parse("quic://:3141");
847        // This might parse as empty host or fail depending on URL parser
848        if let Ok(dsn) = result {
849            assert!(dsn.host().is_empty() || dsn.host() == "");
850        }
851    }
852
853    // ==========================================================================
854    // Query Parameter Tests
855    // ==========================================================================
856
857    #[test]
858    fn test_dsn_parse_all_options() {
859        let dsn = Dsn::parse(
860            "grpc://localhost:50051?tls=1&insecure=false&page_size=2000&client_name=test-app&conformance=full"
861        ).unwrap();
862
863        assert!(dsn.tls_enabled());
864        assert!(!dsn.skip_verify());
865        assert_eq!(dsn.page_size(), 2000);
866        assert_eq!(dsn.client_name(), "test-app");
867        assert_eq!(dsn.conformance(), "full");
868    }
869
870    #[test]
871    fn test_dsn_parse_unknown_options_preserved() {
872        let dsn = Dsn::parse("quic://localhost:3141?custom_option=value").unwrap();
873        assert_eq!(
874            dsn.options().get("custom_option"),
875            Some(&"value".to_string())
876        );
877    }
878
879    #[test]
880    fn test_dsn_parse_option_aliases() {
881        // Test username/user alias
882        let dsn1 = Dsn::parse("grpc://localhost:50051?user=admin").unwrap();
883        let dsn2 = Dsn::parse("grpc://localhost:50051?username=admin").unwrap();
884        assert_eq!(dsn1.username(), dsn2.username());
885
886        // Test password/pass alias
887        let dsn3 = Dsn::parse("grpc://localhost:50051?pass=secret").unwrap();
888        let dsn4 = Dsn::parse("grpc://localhost:50051?password=secret").unwrap();
889        assert_eq!(dsn3.password(), dsn4.password());
890
891        // Test skip_verify/insecure alias
892        let dsn5 = Dsn::parse("grpc://localhost:50051?skip_verify=true").unwrap();
893        let dsn6 = Dsn::parse("grpc://localhost:50051?insecure=true").unwrap();
894        assert_eq!(dsn5.skip_verify(), dsn6.skip_verify());
895    }
896
897    #[test]
898    fn test_dsn_parse_percent_encoded_values() {
899        let dsn = Dsn::parse("quic://localhost:3141?client_name=My%20App").unwrap();
900        assert_eq!(dsn.client_name(), "My App");
901    }
902
903    #[test]
904    fn test_dsn_parse_mtls_options() {
905        let dsn = Dsn::parse(
906            "grpc://localhost:50051?ca=/path/ca.crt&cert=/path/client.crt&key=/path/client.key",
907        )
908        .unwrap();
909        assert_eq!(dsn.ca_cert(), Some("/path/ca.crt"));
910        assert_eq!(dsn.client_cert(), Some("/path/client.crt"));
911        assert_eq!(dsn.client_key(), Some("/path/client.key"));
912    }
913
914    #[test]
915    fn test_dsn_parse_mtls_options_alt_names() {
916        let dsn = Dsn::parse("grpc://localhost:50051?ca_cert=/path/ca.crt&client_cert=/path/client.crt&client_key=/path/client.key").unwrap();
917        assert_eq!(dsn.ca_cert(), Some("/path/ca.crt"));
918        assert_eq!(dsn.client_cert(), Some("/path/client.crt"));
919        assert_eq!(dsn.client_key(), Some("/path/client.key"));
920    }
921
922    #[test]
923    fn test_dsn_parse_connect_timeout() {
924        let dsn = Dsn::parse("quic://localhost:3141?connect_timeout=60").unwrap();
925        assert_eq!(dsn.connect_timeout_secs(), Some(60));
926    }
927
928    #[test]
929    fn test_dsn_parse_server_name() {
930        let dsn = Dsn::parse("quic://localhost:3141?server_name=geode.example.com").unwrap();
931        assert_eq!(dsn.server_name(), Some("geode.example.com"));
932    }
933
934    // ==========================================================================
935    // insecure_tls_skip_verify Parameter Tests (Unified DSN)
936    // ==========================================================================
937
938    #[test]
939    fn test_dsn_parse_insecure_tls_skip_verify_primary() {
940        // Primary parameter name
941        let dsn = Dsn::parse("quic://localhost:3141?insecure_tls_skip_verify=true").unwrap();
942        assert!(dsn.skip_verify());
943
944        let dsn = Dsn::parse("quic://localhost:3141?insecure_tls_skip_verify=1").unwrap();
945        assert!(dsn.skip_verify());
946
947        let dsn = Dsn::parse("quic://localhost:3141?insecure_tls_skip_verify=yes").unwrap();
948        assert!(dsn.skip_verify());
949
950        let dsn = Dsn::parse("quic://localhost:3141?insecure_tls_skip_verify=on").unwrap();
951        assert!(dsn.skip_verify());
952
953        let dsn = Dsn::parse("quic://localhost:3141?insecure_tls_skip_verify=false").unwrap();
954        assert!(!dsn.skip_verify());
955    }
956
957    #[test]
958    fn test_dsn_parse_insecure_alias() {
959        // Alias: insecure
960        let dsn = Dsn::parse("quic://localhost:3141?insecure=true").unwrap();
961        assert!(dsn.skip_verify());
962
963        let dsn = Dsn::parse("grpc://localhost:50051?insecure=1").unwrap();
964        assert!(dsn.skip_verify());
965
966        let dsn = Dsn::parse("localhost:3141?insecure=yes").unwrap();
967        assert!(dsn.skip_verify());
968    }
969
970    #[test]
971    fn test_dsn_parse_skip_verify_alias() {
972        // Alias: skip_verify
973        let dsn = Dsn::parse("quic://localhost:3141?skip_verify=true").unwrap();
974        assert!(dsn.skip_verify());
975
976        let dsn = Dsn::parse("grpc://localhost:50051?skip_verify=1").unwrap();
977        assert!(dsn.skip_verify());
978
979        let dsn = Dsn::parse("quic://localhost:3141?skip_verify=on").unwrap();
980        assert!(dsn.skip_verify());
981    }
982
983    #[test]
984    fn test_dsn_parse_insecure_skip_verify_alias() {
985        // Alias: insecure_skip_verify
986        let dsn = Dsn::parse("quic://localhost:3141?insecure_skip_verify=true").unwrap();
987        assert!(dsn.skip_verify());
988
989        let dsn = Dsn::parse("grpc://localhost:50051?insecure_skip_verify=1").unwrap();
990        assert!(dsn.skip_verify());
991
992        let dsn = Dsn::parse("localhost:3141?insecure_skip_verify=on").unwrap();
993        assert!(dsn.skip_verify());
994
995        let dsn = Dsn::parse("quic://localhost:3141?insecure_skip_verify=false").unwrap();
996        assert!(!dsn.skip_verify());
997    }
998
999    #[test]
1000    fn test_dsn_skip_verify_default_false() {
1001        // Default should be false
1002        let dsn = Dsn::parse("quic://localhost:3141").unwrap();
1003        assert!(!dsn.skip_verify());
1004
1005        let dsn = Dsn::parse("grpc://localhost:50051").unwrap();
1006        assert!(!dsn.skip_verify());
1007
1008        let dsn = Dsn::parse("localhost:3141").unwrap();
1009        assert!(!dsn.skip_verify());
1010    }
1011
1012    // ==========================================================================
1013    // Transport Display Tests
1014    // ==========================================================================
1015
1016    #[test]
1017    fn test_transport_display() {
1018        assert_eq!(Transport::Quic.to_string(), "quic");
1019        assert_eq!(Transport::Grpc.to_string(), "grpc");
1020    }
1021
1022    // ==========================================================================
1023    // Address Formatting Tests
1024    // ==========================================================================
1025
1026    #[test]
1027    fn test_dsn_address_ipv4() {
1028        let dsn = Dsn::parse("quic://192.168.1.1:3141").unwrap();
1029        assert_eq!(dsn.address(), "192.168.1.1:3141");
1030    }
1031
1032    #[test]
1033    fn test_dsn_address_hostname() {
1034        let dsn = Dsn::parse("grpc://geode.example.com:50051").unwrap();
1035        assert_eq!(dsn.address(), "geode.example.com:50051");
1036    }
1037
1038    // ==========================================================================
1039    // Acceptance Tests (from task requirements)
1040    // ==========================================================================
1041
1042    #[test]
1043    fn test_acceptance_quic_localhost() {
1044        // DSN parse: quic://localhost:1234 ⇒ transport=QUIC, host=localhost, port=1234
1045        let dsn = Dsn::parse("quic://localhost:1234").unwrap();
1046        assert_eq!(dsn.transport(), Transport::Quic);
1047        assert_eq!(dsn.host(), "localhost");
1048        assert_eq!(dsn.port(), 1234);
1049    }
1050
1051    #[test]
1052    fn test_acceptance_grpc_with_tls_disabled() {
1053        // DSN parse: grpc://127.0.0.1:50051?tls=0 ⇒ transport=GRPC, host=127.0.0.1, port=50051, tls disabled
1054        let dsn = Dsn::parse("grpc://127.0.0.1:50051?tls=0").unwrap();
1055        assert_eq!(dsn.transport(), Transport::Grpc);
1056        assert_eq!(dsn.host(), "127.0.0.1");
1057        assert_eq!(dsn.port(), 50051);
1058        assert!(!dsn.tls_enabled());
1059    }
1060
1061    #[test]
1062    fn test_acceptance_invalid_scheme() {
1063        // DSN parse invalid: http://localhost:1 ⇒ deterministic "unsupported scheme" error
1064        let err = Dsn::parse("http://localhost:1").unwrap_err();
1065        let err_str = err.to_string();
1066        assert!(
1067            err_str.contains("Unsupported scheme") || err_str.contains("unsupported"),
1068            "Expected 'unsupported scheme' error, got: {}",
1069            err_str
1070        );
1071    }
1072
1073    // ==========================================================================
1074    // Security Tests
1075    // ==========================================================================
1076
1077    #[test]
1078    fn test_dsn_debug_redacts_password() {
1079        let dsn = Dsn::parse("quic://user:secret@localhost:3141").unwrap();
1080        let debug_str = format!("{:?}", dsn);
1081        assert!(
1082            !debug_str.contains("secret"),
1083            "Debug output must not contain password"
1084        );
1085        assert!(
1086            debug_str.contains("REDACTED"),
1087            "Debug output must show REDACTED"
1088        );
1089    }
1090
1091    // ==========================================================================
1092    // DSN Round-trip (to_dsn) Tests
1093    // ==========================================================================
1094
1095    fn assert_roundtrip(dsn_str: &str) {
1096        let parsed = Dsn::parse(dsn_str).unwrap();
1097        let formatted = parsed.to_dsn();
1098        let reparsed = Dsn::parse(&formatted).unwrap_or_else(|e| {
1099            panic!(
1100                "reparse of {:?} (from {:?}) failed: {}",
1101                formatted, dsn_str, e
1102            )
1103        });
1104        assert_eq!(
1105            parsed, reparsed,
1106            "round-trip mismatch: {:?} -> {:?}",
1107            dsn_str, formatted
1108        );
1109    }
1110
1111    #[test]
1112    fn test_to_dsn_roundtrip_quic_basic() {
1113        assert_roundtrip("quic://localhost:3141");
1114    }
1115
1116    #[test]
1117    fn test_to_dsn_roundtrip_grpc_tls_disabled() {
1118        assert_roundtrip("grpc://127.0.0.1:50051?tls=0");
1119    }
1120
1121    #[test]
1122    fn test_to_dsn_roundtrip_page_size() {
1123        assert_roundtrip("quic://localhost:3141?page_size=2500");
1124    }
1125
1126    #[test]
1127    fn test_to_dsn_roundtrip_graph() {
1128        assert_roundtrip("quic://localhost:3141?graph=mygraph");
1129    }
1130
1131    #[test]
1132    fn test_to_dsn_roundtrip_userpass() {
1133        assert_roundtrip("quic://admin:secret@localhost:3141");
1134    }
1135
1136    #[test]
1137    fn test_to_dsn_roundtrip_user_only() {
1138        assert_roundtrip("quic://admin@localhost:3141");
1139    }
1140
1141    #[test]
1142    fn test_to_dsn_roundtrip_password_only() {
1143        // password-only falls back to ?pass= (matches Go fix 6aee266)
1144        let parsed = Dsn::parse("quic://localhost:3141?pass=secret").unwrap();
1145        let formatted = parsed.to_dsn();
1146        assert!(
1147            formatted.contains("pass=secret"),
1148            "password-only DSN should emit pass= query param, got: {}",
1149            formatted
1150        );
1151        let reparsed = Dsn::parse(&formatted).unwrap();
1152        assert_eq!(parsed, reparsed);
1153        assert_eq!(reparsed.password(), Some("secret"));
1154        assert_eq!(reparsed.username(), None);
1155    }
1156
1157    #[test]
1158    fn test_to_dsn_roundtrip_ipv6() {
1159        assert_roundtrip("quic://[::1]:3141");
1160        assert_roundtrip("grpc://[2001:db8::1]:50051?tls=0");
1161    }
1162
1163    #[test]
1164    fn test_to_dsn_roundtrip_skip_verify() {
1165        assert_roundtrip("quic://localhost:3141?insecure_tls_skip_verify=true");
1166    }
1167
1168    #[test]
1169    fn test_to_dsn_roundtrip_full() {
1170        assert_roundtrip(
1171            "grpc://admin:p%40ss@host.example.com:50051?tls=0&page_size=500&conformance=full&graph=g1&connect_timeout=60",
1172        );
1173    }
1174
1175    #[test]
1176    fn test_to_dsn_scheme_present() {
1177        let dsn = Dsn::parse("grpc://localhost:50051").unwrap();
1178        assert!(dsn.to_dsn().starts_with("grpc://"));
1179        let dsn = Dsn::parse("quic://localhost:3141").unwrap();
1180        assert!(dsn.to_dsn().starts_with("quic://"));
1181    }
1182
1183    #[test]
1184    fn test_dsn_debug_no_password_shows_none() {
1185        let dsn = Dsn::parse("quic://localhost:3141").unwrap();
1186        let debug_str = format!("{:?}", dsn);
1187        assert!(
1188            debug_str.contains("password: None"),
1189            "Debug output should show None when no password is set"
1190        );
1191    }
1192}