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