Skip to main content

pg_client/
url.rs

1use crate::config::{Endpoint, Host, Password, Port, Session, SslMode, SslRootCert};
2use crate::{Config, Database, User};
3use fluent_uri::pct_enc::EStr;
4use std::collections::{BTreeMap, BTreeSet};
5use std::fmt;
6
7#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
8pub enum ParseError {
9    #[error("Invalid URL: {0}")]
10    InvalidUrl(#[from] ::fluent_uri::ParseError),
11    #[error("Invalid URL scheme: expected 'postgres' or 'postgresql', got '{0}'")]
12    InvalidScheme(String),
13    #[error("Invalid URL fragment: '{0}'")]
14    InvalidFragment(String),
15    #[error("Missing host in URL")]
16    MissingHost,
17    #[error("Missing required parameter '{0}' in URL")]
18    MissingParameter(&'static str),
19    #[error("Parameter '{0}' specified in both URL and query string")]
20    ConflictingParameter(&'static str),
21    #[error("Unknown query parameter: '{0}'")]
22    InvalidQueryParameter(String),
23    #[error("Invalid query parameter encoding: {0}")]
24    InvalidQueryParameterEncoding(std::str::Utf8Error),
25    #[error("{0}")]
26    Field(#[from] FieldError),
27    #[error("Unsupported parameter for socket path connection: '{0}'")]
28    UnsupportedSocketPathParameter(&'static str),
29    #[error("Invalid port: {0}")]
30    InvalidPort(#[from] std::num::ParseIntError),
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum FieldSource {
35    Authority,
36    Path,
37    QueryParam,
38}
39
40impl fmt::Display for FieldSource {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        match self {
43            FieldSource::Authority => f.write_str("authority"),
44            FieldSource::Path => f.write_str("path"),
45            FieldSource::QueryParam => f.write_str("query"),
46        }
47    }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum Field {
52    User,
53    Password,
54    Database,
55    Host,
56    HostAddr,
57    SslMode,
58    SslRootCert,
59    ApplicationName,
60    ChannelBinding,
61}
62
63impl fmt::Display for Field {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        match self {
66            Field::User => f.write_str("user"),
67            Field::Password => f.write_str("password"),
68            Field::Database => f.write_str("dbname"),
69            Field::Host => f.write_str("host"),
70            Field::HostAddr => f.write_str("hostaddr"),
71            Field::SslMode => f.write_str("sslmode"),
72            Field::SslRootCert => f.write_str("sslrootcert"),
73            Field::ApplicationName => f.write_str("application_name"),
74            Field::ChannelBinding => f.write_str("channel_binding"),
75        }
76    }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum FieldErrorCause {
81    InvalidUtf8(std::str::Utf8Error),
82    InvalidIdentifier(crate::identifier::ParseError),
83    InvalidValue(String),
84}
85
86impl fmt::Display for FieldErrorCause {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        match self {
89            FieldErrorCause::InvalidUtf8(error) => {
90                write!(f, "invalid utf-8 encoding: {error}")
91            }
92            FieldErrorCause::InvalidIdentifier(error) => {
93                write!(f, "invalid value: {error}")
94            }
95            FieldErrorCause::InvalidValue(error) if error.is_empty() => {
96                f.write_str("invalid value")
97            }
98            FieldErrorCause::InvalidValue(error) => write!(f, "invalid value: {error}"),
99        }
100    }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
104#[error("Invalid {field} in {origin}: {cause}")]
105pub struct FieldError {
106    pub origin: FieldSource,
107    pub field: Field,
108    pub cause: FieldErrorCause,
109}
110
111/// Parse a PostgreSQL connection URL into a Config.
112///
113/// Supports both `postgres://` and `postgresql://` schemes.
114///
115/// When the URL does not specify `sslmode`, it defaults to `verify-full`
116/// to ensure secure connections by default.
117///
118/// # URL Format
119///
120/// Network connections:
121/// ```text
122/// postgres://username:password@host:port/database?params
123/// ```
124///
125/// Socket connections (via query params when host starts with `/` or `@`):
126/// ```text
127/// postgres://?host=/path/to/socket&user=username&dbname=database
128/// ```
129///
130/// Cloud SQL connections (user/password/database in URL, socket path in query):
131/// ```text
132/// postgres://username:password@/database?host=/cloudsql/project:region:instance
133/// ```
134///
135/// # Query Parameters
136///
137/// - `sslmode`: SSL mode (allow, disable, prefer, require, verify-ca, verify-full)
138/// - `sslrootcert`: Path to SSL root certificate or "system"
139/// - `application_name`: Application name
140/// - `hostaddr`: IP address for the host
141/// - `channel_binding`: Channel binding (disable, prefer, require)
142/// - `host`: Socket path (when URL has no host component)
143/// - `user`: User (when URL has no username component)
144/// - `dbname`: Database name (when URL has no path component)
145/// - `password`: Password (when URL has no password component)
146///
147/// # Errors
148///
149/// Returns an error if the same parameter is specified both in the URL
150/// components and as a query parameter (e.g., password in both places).
151///
152/// # Example
153///
154/// ```
155/// use pg_client::{Config, config::SslMode};
156///
157/// let config = pg_client::url::parse(
158///     "postgres://user@localhost:5432/mydb",
159/// ).unwrap();
160///
161/// assert_eq!(config.session.user.as_str(), "user");
162/// assert_eq!(config.session.database.as_str(), "mydb");
163/// assert_eq!(config.ssl_mode, SslMode::VerifyFull);
164/// ```
165pub fn parse(url: &str) -> Result<Config, ParseError> {
166    let uri = ::fluent_uri::Uri::parse(url)?;
167
168    // Validate scheme
169    let scheme = uri.scheme().as_str();
170    if scheme != "postgres" && scheme != "postgresql" {
171        return Err(ParseError::InvalidScheme(scheme.to_string()));
172    }
173
174    if let Some(fragment) = uri.fragment() {
175        return Err(ParseError::InvalidFragment(fragment.as_str().to_string()));
176    }
177
178    // Parse query string into decoded key-value map
179    let query_map = parse_query(uri.query())?;
180    let mut query_params = QueryParams::new(&query_map);
181
182    // Extract userinfo (user and password from URL authority)
183    let authority = uri.authority();
184    let (url_user, url_password) = extract_userinfo(authority.as_ref())?;
185
186    // Extract database from URL path
187    let url_database = decode_path_database(uri.path())?;
188
189    // Resolve endpoint from authority host or query host parameter
190    let query_host = query_params.take("host");
191
192    let endpoint = match authority.as_ref() {
193        Some(authority) if !authority.host().is_empty() => {
194            if query_host.is_some() {
195                return Err(ParseError::ConflictingParameter("host"));
196            }
197
198            let host = match authority.host_parsed() {
199                fluent_uri::component::Host::RegName(name) => {
200                    let decoded = decode_to_string(name).map_err(|error| FieldError {
201                        origin: FieldSource::Authority,
202                        field: Field::Host,
203                        cause: FieldErrorCause::InvalidUtf8(error),
204                    })?;
205                    decoded.parse::<Host>().map_err(|error: &str| FieldError {
206                        origin: FieldSource::Authority,
207                        field: Field::Host,
208                        cause: FieldErrorCause::InvalidValue(error.to_string()),
209                    })?
210                }
211                fluent_uri::component::Host::Ipv4(addr) => Host::IpAddr(addr.into()),
212                fluent_uri::component::Host::Ipv6(addr) => Host::IpAddr(addr.into()),
213                _ => {
214                    let host = authority.host();
215                    let message = if host.starts_with("[v") || host.starts_with("[V") {
216                        "unsupported host type: ipvfuture"
217                    } else {
218                        "unsupported host type"
219                    };
220                    return Err(FieldError {
221                        origin: FieldSource::Authority,
222                        field: Field::Host,
223                        cause: FieldErrorCause::InvalidValue(message.to_string()),
224                    }
225                    .into());
226                }
227            };
228
229            let host_addr = match query_params.take("hostaddr") {
230                Some(addr_str) => Some(addr_str.parse().map_err(|error: &str| FieldError {
231                    origin: FieldSource::QueryParam,
232                    field: Field::HostAddr,
233                    cause: FieldErrorCause::InvalidValue(error.to_string()),
234                })?),
235                None => None,
236            };
237
238            let channel_binding = match query_params.take("channel_binding") {
239                Some(binding_str) => Some(binding_str.parse().map_err(|_| FieldError {
240                    origin: FieldSource::QueryParam,
241                    field: Field::ChannelBinding,
242                    cause: FieldErrorCause::InvalidValue(binding_str.to_string()),
243                })?),
244                None => None,
245            };
246
247            let port = authority.port_to_u16()?.map(Port::new);
248
249            Endpoint::Network {
250                host,
251                channel_binding,
252                host_addr,
253                port,
254            }
255        }
256        _ => {
257            let host = query_host.ok_or(ParseError::MissingHost)?;
258
259            if !host.starts_with('/') && !host.starts_with('@') {
260                return Err(FieldError {
261                    origin: FieldSource::QueryParam,
262                    field: Field::Host,
263                    cause: FieldErrorCause::InvalidValue(
264                        "query host must be a socket path (start with / or @)".to_string(),
265                    ),
266                }
267                .into());
268            }
269
270            for name in ["channel_binding", "hostaddr"] {
271                if query_params.take(name).is_some() {
272                    return Err(ParseError::UnsupportedSocketPathParameter(name));
273                }
274            }
275
276            Endpoint::SocketPath(host.into())
277        }
278    };
279
280    let user_value = access_field(
281        "user",
282        url_user.map(|value| FieldValue::new(FieldSource::Authority, value)),
283        &mut query_params,
284    )?
285    .ok_or(ParseError::MissingParameter("user"))?;
286    if user_value.value.is_empty() {
287        return Err(ParseError::MissingParameter("user"));
288    }
289    let user: User = user_value.value.parse().map_err(|error| FieldError {
290        origin: user_value.origin,
291        field: Field::User,
292        cause: FieldErrorCause::InvalidIdentifier(error),
293    })?;
294
295    let password: Option<Password> = match access_field(
296        "password",
297        url_password.map(|value| FieldValue::new(FieldSource::Authority, value)),
298        &mut query_params,
299    )? {
300        Some(value) => Some(value.value.parse().map_err(|error: String| FieldError {
301            origin: value.origin,
302            field: Field::Password,
303            cause: FieldErrorCause::InvalidValue(error.to_string()),
304        })?),
305        None => None,
306    };
307
308    let database_value = access_field(
309        "dbname",
310        url_database.map(|value| FieldValue::new(FieldSource::Path, value)),
311        &mut query_params,
312    )?
313    .ok_or(ParseError::MissingParameter("dbname"))?;
314    let database: Database = database_value.value.parse().map_err(|error| FieldError {
315        origin: database_value.origin,
316        field: Field::Database,
317        cause: FieldErrorCause::InvalidIdentifier(error),
318    })?;
319
320    // Parse sslmode, defaulting to verify-full for secure connections
321    let ssl_mode = match query_params.take("sslmode") {
322        Some(mode_str) => mode_str.parse().map_err(|_| FieldError {
323            origin: FieldSource::QueryParam,
324            field: Field::SslMode,
325            cause: FieldErrorCause::InvalidValue(mode_str.to_string()),
326        })?,
327        None => SslMode::VerifyFull,
328    };
329
330    // Parse sslrootcert
331    let ssl_root_cert = query_params.take("sslrootcert").map(|cert_str| {
332        if cert_str == "system" {
333            SslRootCert::System
334        } else {
335            SslRootCert::File(cert_str.to_string().into())
336        }
337    });
338
339    // Parse application_name
340    let application_name = match query_params.take("application_name") {
341        Some(name_str) => Some(name_str.parse().map_err(|error: String| FieldError {
342            origin: FieldSource::QueryParam,
343            field: Field::ApplicationName,
344            cause: FieldErrorCause::InvalidValue(error),
345        })?),
346        None => None,
347    };
348
349    if let Some(unknown) = query_params.unknown_param() {
350        return Err(ParseError::InvalidQueryParameter(unknown.to_string()));
351    }
352
353    Ok(Config {
354        endpoint,
355        session: Session {
356            application_name,
357            database,
358            password,
359            user,
360        },
361        ssl_mode,
362        ssl_root_cert,
363        #[cfg(feature = "sqlx")]
364        sqlx: Default::default(),
365    })
366}
367
368fn extract_userinfo(
369    authority: Option<&fluent_uri::component::Authority<'_>>,
370) -> Result<(Option<String>, Option<String>), ParseError> {
371    let userinfo = match authority.and_then(|authority| authority.userinfo()) {
372        Some(info) => info,
373        None => return Ok((None, None)),
374    };
375
376    match userinfo.split_once(':') {
377        Some((user_enc, pass_enc)) => {
378            let user = decode_to_string(user_enc).map_err(|error| FieldError {
379                origin: FieldSource::Authority,
380                field: Field::User,
381                cause: FieldErrorCause::InvalidUtf8(error),
382            })?;
383            let password = decode_to_string(pass_enc).map_err(|error| FieldError {
384                origin: FieldSource::Authority,
385                field: Field::Password,
386                cause: FieldErrorCause::InvalidUtf8(error),
387            })?;
388            let user = non_empty(user);
389            let password = non_empty(password);
390            Ok((user, password))
391        }
392        None => {
393            let user = decode_to_string(userinfo).map_err(|error| FieldError {
394                origin: FieldSource::Authority,
395                field: Field::User,
396                cause: FieldErrorCause::InvalidUtf8(error),
397            })?;
398            Ok((non_empty(user), None))
399        }
400    }
401}
402
403fn decode_to_string<E: fluent_uri::pct_enc::Encoder>(
404    estr: &EStr<E>,
405) -> Result<String, std::str::Utf8Error> {
406    let bytes = estr.decode().to_bytes();
407    String::from_utf8(bytes.into_owned()).map_err(|error| error.utf8_error())
408}
409
410fn non_empty(value: String) -> Option<String> {
411    if value.is_empty() { None } else { Some(value) }
412}
413
414fn decode_path_database(
415    path: &EStr<fluent_uri::pct_enc::encoder::Path>,
416) -> Result<Option<String>, ParseError> {
417    let decoded = decode_to_string(path).map_err(|error| FieldError {
418        origin: FieldSource::Path,
419        field: Field::Database,
420        cause: FieldErrorCause::InvalidUtf8(error),
421    })?;
422
423    let stripped = decoded.strip_prefix('/').unwrap_or(&decoded);
424
425    Ok(non_empty(stripped.to_string()))
426}
427
428fn parse_query(
429    query: Option<&EStr<fluent_uri::pct_enc::encoder::Query>>,
430) -> Result<BTreeMap<String, String>, ParseError> {
431    let query = match query {
432        Some(query) => query,
433        None => return Ok(BTreeMap::new()),
434    };
435
436    query
437        .split('&')
438        .map(|pair| {
439            let (key, value) = pair.split_once('=').unwrap_or((pair, EStr::EMPTY));
440            let key = decode_to_string(key).map_err(ParseError::InvalidQueryParameterEncoding)?;
441            let field = query_field(&key);
442            let value = decode_to_string(value).map_err(|error| match field {
443                Some(field) => FieldError {
444                    origin: FieldSource::QueryParam,
445                    field,
446                    cause: FieldErrorCause::InvalidUtf8(error),
447                }
448                .into(),
449                None => ParseError::InvalidQueryParameterEncoding(error),
450            })?;
451            Ok((key, value))
452        })
453        .collect()
454}
455
456fn access_field(
457    name: &'static str,
458    url_value: Option<FieldValue>,
459    query_params: &mut QueryParams<'_>,
460) -> Result<Option<FieldValue>, ParseError> {
461    let query_value = query_params
462        .take(name)
463        .map(|value| FieldValue::new(FieldSource::QueryParam, value.to_string()));
464    match (url_value, query_value) {
465        (Some(_), Some(_)) => Err(ParseError::ConflictingParameter(name)),
466        (Some(value), None) => Ok(Some(value)),
467        (None, Some(value)) => Ok(Some(value)),
468        (None, None) => Ok(None),
469    }
470}
471
472#[derive(Debug, Clone, PartialEq, Eq)]
473struct FieldValue {
474    origin: FieldSource,
475    value: String,
476}
477
478impl FieldValue {
479    fn new(origin: FieldSource, value: String) -> Self {
480        Self { origin, value }
481    }
482}
483
484fn query_field(name: &str) -> Option<Field> {
485    match name {
486        "user" => Some(Field::User),
487        "password" => Some(Field::Password),
488        "dbname" => Some(Field::Database),
489        "host" => Some(Field::Host),
490        "hostaddr" => Some(Field::HostAddr),
491        "sslmode" => Some(Field::SslMode),
492        "sslrootcert" => Some(Field::SslRootCert),
493        "application_name" => Some(Field::ApplicationName),
494        "channel_binding" => Some(Field::ChannelBinding),
495        _ => None,
496    }
497}
498
499struct QueryParams<'a> {
500    params: &'a BTreeMap<String, String>,
501    remaining: BTreeSet<&'a str>,
502}
503
504impl<'a> QueryParams<'a> {
505    fn new(params: &'a BTreeMap<String, String>) -> Self {
506        let remaining = params.keys().map(|key| key.as_str()).collect();
507        Self { params, remaining }
508    }
509
510    fn take(&mut self, name: &str) -> Option<&'a str> {
511        let value = self.params.get(name).map(|value| value.as_str());
512        if value.is_some() {
513            self.remaining.remove(name);
514        }
515        value
516    }
517
518    fn unknown_param(&self) -> Option<&&'a str> {
519        self.remaining.iter().next()
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526    use crate::config::{ChannelBinding, SslMode};
527
528    fn network(host: &str, port: Option<u16>, host_addr: Option<&str>) -> Endpoint {
529        Endpoint::Network {
530            host: host.parse().unwrap(),
531            channel_binding: None,
532            port: port.map(Port::new),
533            host_addr: host_addr.map(|address| address.parse().unwrap()),
534        }
535    }
536
537    fn success(
538        user: &str,
539        password: Option<&str>,
540        database: &str,
541        endpoint: Endpoint,
542        ssl_mode: SslMode,
543        ssl_root_cert: Option<SslRootCert>,
544        application_name: Option<&str>,
545    ) -> Config {
546        Config {
547            endpoint,
548            session: Session {
549                user: user.parse().unwrap(),
550                password: password.map(|value| value.parse().unwrap()),
551                database: database.parse().unwrap(),
552                application_name: application_name.map(|value| value.parse().unwrap()),
553            },
554            ssl_mode,
555            ssl_root_cert,
556            #[cfg(feature = "sqlx")]
557            sqlx: Default::default(),
558        }
559    }
560
561    fn field_error(origin: FieldSource, field: Field, cause: FieldErrorCause) -> ParseError {
562        ParseError::Field(FieldError {
563            origin,
564            field,
565            cause,
566        })
567    }
568
569    #[test]
570    fn test_parse() {
571        type Expected = Result<Config, ParseError>;
572
573        let cases: Vec<(&str, &str, Expected)> = vec![
574            // Success cases
575            (
576                "basic_network",
577                "postgres://user@localhost:5432/mydb",
578                Ok(success(
579                    "user",
580                    None,
581                    "mydb",
582                    network("localhost", Some(5432), None),
583                    SslMode::VerifyFull,
584                    None,
585                    None,
586                )),
587            ),
588            (
589                "with_password",
590                "postgres://user:secret@localhost/mydb",
591                Ok(success(
592                    "user",
593                    Some("secret"),
594                    "mydb",
595                    network("localhost", None, None),
596                    SslMode::VerifyFull,
597                    None,
598                    None,
599                )),
600            ),
601            (
602                "percent_encoded_password",
603                "postgres://user:p%40ss%2Fword@localhost/mydb",
604                Ok(success(
605                    "user",
606                    Some("p@ss/word"),
607                    "mydb",
608                    network("localhost", None, None),
609                    SslMode::VerifyFull,
610                    None,
611                    None,
612                )),
613            ),
614            (
615                "with_sslmode_disable",
616                "postgres://user@localhost/mydb?sslmode=disable",
617                Ok(success(
618                    "user",
619                    None,
620                    "mydb",
621                    network("localhost", None, None),
622                    SslMode::Disable,
623                    None,
624                    None,
625                )),
626            ),
627            (
628                "with_sslmode_require",
629                "postgres://user@localhost/mydb?sslmode=require",
630                Ok(success(
631                    "user",
632                    None,
633                    "mydb",
634                    network("localhost", None, None),
635                    SslMode::Require,
636                    None,
637                    None,
638                )),
639            ),
640            (
641                "with_channel_binding",
642                "postgres://user@localhost/mydb?channel_binding=require",
643                Ok(success(
644                    "user",
645                    None,
646                    "mydb",
647                    Endpoint::Network {
648                        host: "localhost".parse().unwrap(),
649                        channel_binding: Some(ChannelBinding::Require),
650                        port: None,
651                        host_addr: None,
652                    },
653                    SslMode::VerifyFull,
654                    None,
655                    None,
656                )),
657            ),
658            (
659                "with_application_name",
660                "postgres://user@localhost/mydb?application_name=myapp",
661                Ok(success(
662                    "user",
663                    None,
664                    "mydb",
665                    network("localhost", None, None),
666                    SslMode::VerifyFull,
667                    None,
668                    Some("myapp"),
669                )),
670            ),
671            (
672                "with_hostaddr",
673                "postgres://user@example.com/mydb?hostaddr=192.168.1.1",
674                Ok(success(
675                    "user",
676                    None,
677                    "mydb",
678                    network("example.com", None, Some("192.168.1.1")),
679                    SslMode::VerifyFull,
680                    None,
681                    None,
682                )),
683            ),
684            (
685                "with_sslrootcert_file",
686                "postgres://user@localhost/mydb?sslrootcert=/path/to/cert.pem",
687                Ok(success(
688                    "user",
689                    None,
690                    "mydb",
691                    network("localhost", None, None),
692                    SslMode::VerifyFull,
693                    Some(SslRootCert::File("/path/to/cert.pem".into())),
694                    None,
695                )),
696            ),
697            (
698                "with_sslrootcert_system",
699                "postgres://user@localhost/mydb?sslrootcert=system",
700                Ok(success(
701                    "user",
702                    None,
703                    "mydb",
704                    network("localhost", None, None),
705                    SslMode::VerifyFull,
706                    Some(SslRootCert::System),
707                    None,
708                )),
709            ),
710            (
711                "socket_path",
712                "postgres://?host=/var/run/postgresql&user=postgres&dbname=mydb",
713                Ok(success(
714                    "postgres",
715                    None,
716                    "mydb",
717                    Endpoint::SocketPath("/var/run/postgresql".into()),
718                    SslMode::VerifyFull,
719                    None,
720                    None,
721                )),
722            ),
723            (
724                "socket_with_password",
725                "postgres://?host=/socket&user=user&password=pass&dbname=mydb",
726                Ok(success(
727                    "user",
728                    Some("pass"),
729                    "mydb",
730                    Endpoint::SocketPath("/socket".into()),
731                    SslMode::VerifyFull,
732                    None,
733                    None,
734                )),
735            ),
736            (
737                "abstract_socket",
738                "postgres://?host=@abstract&user=postgres&dbname=mydb",
739                Ok(success(
740                    "postgres",
741                    None,
742                    "mydb",
743                    Endpoint::SocketPath("@abstract".into()),
744                    SslMode::VerifyFull,
745                    None,
746                    None,
747                )),
748            ),
749            (
750                "postgresql_scheme",
751                "postgresql://user@localhost/mydb",
752                Ok(success(
753                    "user",
754                    None,
755                    "mydb",
756                    network("localhost", None, None),
757                    SslMode::VerifyFull,
758                    None,
759                    None,
760                )),
761            ),
762            (
763                "ipv6_host",
764                "postgres://user@[::1]:5432/mydb",
765                Ok(success(
766                    "user",
767                    None,
768                    "mydb",
769                    network("::1", Some(5432), None),
770                    SslMode::VerifyFull,
771                    None,
772                    None,
773                )),
774            ),
775            (
776                "ipv4_host",
777                "postgres://user@192.168.1.1:5432/mydb",
778                Ok(success(
779                    "user",
780                    None,
781                    "mydb",
782                    network("192.168.1.1", Some(5432), None),
783                    SslMode::VerifyFull,
784                    None,
785                    None,
786                )),
787            ),
788            (
789                "no_port",
790                "postgres://user@localhost/mydb",
791                Ok(success(
792                    "user",
793                    None,
794                    "mydb",
795                    network("localhost", None, None),
796                    SslMode::VerifyFull,
797                    None,
798                    None,
799                )),
800            ),
801            // Cloud SQL success cases
802            (
803                "cloud_sql_socket",
804                "postgres://user:secret@/main?host=/cloudsql/project:region:instance",
805                Ok(success(
806                    "user",
807                    Some("secret"),
808                    "main",
809                    Endpoint::SocketPath("/cloudsql/project:region:instance".into()),
810                    SslMode::VerifyFull,
811                    None,
812                    None,
813                )),
814            ),
815            (
816                "cloud_sql_socket_no_password",
817                "postgres://user@/main?host=/cloudsql/project:region:instance",
818                Ok(success(
819                    "user",
820                    None,
821                    "main",
822                    Endpoint::SocketPath("/cloudsql/project:region:instance".into()),
823                    SslMode::VerifyFull,
824                    None,
825                    None,
826                )),
827            ),
828            (
829                "cloud_sql_socket_sslmode_disable",
830                "postgres://user:secret@/main?host=/cloudsql/project:region:instance&sslmode=disable",
831                Ok(success(
832                    "user",
833                    Some("secret"),
834                    "main",
835                    Endpoint::SocketPath("/cloudsql/project:region:instance".into()),
836                    SslMode::Disable,
837                    None,
838                    None,
839                )),
840            ),
841            (
842                "cloud_sql_socket_query_params",
843                "postgres://?host=/cloudsql/project:region:instance&user=user&password=secret&dbname=main",
844                Ok(success(
845                    "user",
846                    Some("secret"),
847                    "main",
848                    Endpoint::SocketPath("/cloudsql/project:region:instance".into()),
849                    SslMode::VerifyFull,
850                    None,
851                    None,
852                )),
853            ),
854            // Error cases
855            (
856                "invalid_scheme",
857                "mysql://user@localhost/mydb",
858                Err(ParseError::InvalidScheme("mysql".to_string())),
859            ),
860            (
861                "missing_username",
862                "postgres://localhost/mydb",
863                Err(ParseError::MissingParameter("user")),
864            ),
865            (
866                "missing_database",
867                "postgres://user@localhost",
868                Err(ParseError::MissingParameter("dbname")),
869            ),
870            (
871                "missing_host",
872                "postgres://?user=user&dbname=mydb",
873                Err(ParseError::MissingHost),
874            ),
875            (
876                "conflicting_host",
877                "postgres://user@localhost/mydb?host=/socket",
878                Err(ParseError::ConflictingParameter("host")),
879            ),
880            (
881                "conflicting_user",
882                "postgres://user@localhost/mydb?user=other",
883                Err(ParseError::ConflictingParameter("user")),
884            ),
885            (
886                "conflicting_password",
887                "postgres://user:secret@localhost/mydb?password=other",
888                Err(ParseError::ConflictingParameter("password")),
889            ),
890            (
891                "conflicting_dbname",
892                "postgres://user@localhost/mydb?dbname=other",
893                Err(ParseError::ConflictingParameter("dbname")),
894            ),
895            (
896                "invalid_sslmode",
897                "postgres://user@localhost/mydb?sslmode=invalid",
898                Err(field_error(
899                    FieldSource::QueryParam,
900                    Field::SslMode,
901                    FieldErrorCause::InvalidValue("invalid".to_string()),
902                )),
903            ),
904            (
905                "invalid_channel_binding",
906                "postgres://user@localhost/mydb?channel_binding=invalid",
907                Err(field_error(
908                    FieldSource::QueryParam,
909                    Field::ChannelBinding,
910                    FieldErrorCause::InvalidValue("invalid".to_string()),
911                )),
912            ),
913            (
914                "invalid_hostaddr",
915                "postgres://user@localhost/mydb?hostaddr=not-an-ip",
916                Err(field_error(
917                    FieldSource::QueryParam,
918                    Field::HostAddr,
919                    FieldErrorCause::InvalidValue("invalid IP address".to_string()),
920                )),
921            ),
922            (
923                "unsupported_ipvfuture_host",
924                "postgres://user@[v1.fe80]/mydb",
925                Err(field_error(
926                    FieldSource::Authority,
927                    Field::Host,
928                    FieldErrorCause::InvalidValue("unsupported host type: ipvfuture".to_string()),
929                )),
930            ),
931            (
932                "unknown_parameter",
933                "postgres://user@localhost/mydb?unknown_parameter=1",
934                Err(ParseError::InvalidQueryParameter(
935                    "unknown_parameter".to_string(),
936                )),
937            ),
938            (
939                "fragment",
940                "postgres://user@localhost/mydb#section",
941                Err(ParseError::InvalidFragment("section".to_string())),
942            ),
943            (
944                "socket_missing_user",
945                "postgres://?host=/socket&dbname=mydb",
946                Err(ParseError::MissingParameter("user")),
947            ),
948            (
949                "socket_missing_dbname",
950                "postgres://?host=/socket&user=user",
951                Err(ParseError::MissingParameter("dbname")),
952            ),
953            (
954                "socket_with_channel_binding",
955                "postgres://?host=/socket&user=user&dbname=mydb&channel_binding=require",
956                Err(ParseError::UnsupportedSocketPathParameter(
957                    "channel_binding",
958                )),
959            ),
960            (
961                "socket_with_hostaddr",
962                "postgres://?host=/socket&user=user&dbname=mydb&hostaddr=127.0.0.1",
963                Err(ParseError::UnsupportedSocketPathParameter("hostaddr")),
964            ),
965            // Cloud SQL error cases
966            (
967                "cloud_sql_conflicting_user",
968                "postgres://user@/main?host=/cloudsql/project:region:instance&user=other",
969                Err(ParseError::ConflictingParameter("user")),
970            ),
971            (
972                "cloud_sql_conflicting_password",
973                "postgres://user:secret@/main?host=/cloudsql/project:region:instance&password=other",
974                Err(ParseError::ConflictingParameter("password")),
975            ),
976            (
977                "cloud_sql_conflicting_dbname",
978                "postgres://user@/main?host=/cloudsql/project:region:instance&dbname=other",
979                Err(ParseError::ConflictingParameter("dbname")),
980            ),
981        ];
982
983        for (name, url_str, expected) in cases {
984            let actual = parse(url_str);
985
986            assert_eq!(actual, expected, "{name}: {url_str}");
987
988            if let Ok(config) = actual {
989                let roundtrip_url = config.to_url_string();
990                let roundtrip_config = parse(&roundtrip_url).unwrap_or_else(|error| {
991                    panic!("{name}: roundtrip parse failed: {error}, url: {roundtrip_url}")
992                });
993                assert_eq!(roundtrip_config, config, "{name}: roundtrip");
994            }
995        }
996    }
997}