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
206                        .parse::<Host>()
207                        .map_err(|error: crate::config::HostParseError| FieldError {
208                            origin: FieldSource::Authority,
209                            field: Field::Host,
210                            cause: FieldErrorCause::InvalidValue(error.to_string()),
211                        })?
212                }
213                fluent_uri::component::Host::Ipv4(addr) => Host::IpAddr(addr.into()),
214                fluent_uri::component::Host::Ipv6(addr) => Host::IpAddr(addr.into()),
215                _ => {
216                    let host = authority.host();
217                    let message = if host.starts_with("[v") || host.starts_with("[V") {
218                        "unsupported host type: ipvfuture"
219                    } else {
220                        "unsupported host type"
221                    };
222                    return Err(FieldError {
223                        origin: FieldSource::Authority,
224                        field: Field::Host,
225                        cause: FieldErrorCause::InvalidValue(message.to_string()),
226                    }
227                    .into());
228                }
229            };
230
231            let host_addr = match query_params.take("hostaddr") {
232                Some(addr_str) => Some(addr_str.parse().map_err(
233                    |error: crate::config::HostAddrParseError| FieldError {
234                        origin: FieldSource::QueryParam,
235                        field: Field::HostAddr,
236                        cause: FieldErrorCause::InvalidValue(error.to_string()),
237                    },
238                )?),
239                None => None,
240            };
241
242            let channel_binding = match query_params.take("channel_binding") {
243                Some(binding_str) => Some(binding_str.parse().map_err(|_| FieldError {
244                    origin: FieldSource::QueryParam,
245                    field: Field::ChannelBinding,
246                    cause: FieldErrorCause::InvalidValue(binding_str.to_string()),
247                })?),
248                None => None,
249            };
250
251            let port = authority.port_to_u16()?.map(Port::new);
252
253            Endpoint::Network {
254                host,
255                channel_binding,
256                host_addr,
257                port,
258            }
259        }
260        _ => {
261            let host = query_host.ok_or(ParseError::MissingHost)?;
262
263            if !host.starts_with('/') && !host.starts_with('@') {
264                return Err(FieldError {
265                    origin: FieldSource::QueryParam,
266                    field: Field::Host,
267                    cause: FieldErrorCause::InvalidValue(
268                        "query host must be a socket path (start with / or @)".to_string(),
269                    ),
270                }
271                .into());
272            }
273
274            for name in ["channel_binding", "hostaddr"] {
275                if query_params.take(name).is_some() {
276                    return Err(ParseError::UnsupportedSocketPathParameter(name));
277                }
278            }
279
280            Endpoint::SocketPath(host.into())
281        }
282    };
283
284    let user_value = access_field(
285        "user",
286        url_user.map(|value| FieldValue::new(FieldSource::Authority, value)),
287        &mut query_params,
288    )?
289    .ok_or(ParseError::MissingParameter("user"))?;
290    if user_value.value.is_empty() {
291        return Err(ParseError::MissingParameter("user"));
292    }
293    let user: User = user_value.value.parse().map_err(|error| FieldError {
294        origin: user_value.origin,
295        field: Field::User,
296        cause: FieldErrorCause::InvalidIdentifier(error),
297    })?;
298
299    let password: Option<Password> = match access_field(
300        "password",
301        url_password.map(|value| FieldValue::new(FieldSource::Authority, value)),
302        &mut query_params,
303    )? {
304        Some(value) => Some(value.value.parse().map_err(
305            |error: crate::config::PasswordParseError| FieldError {
306                origin: value.origin,
307                field: Field::Password,
308                cause: FieldErrorCause::InvalidValue(error.to_string()),
309            },
310        )?),
311        None => None,
312    };
313
314    let database_value = access_field(
315        "dbname",
316        url_database.map(|value| FieldValue::new(FieldSource::Path, value)),
317        &mut query_params,
318    )?
319    .ok_or(ParseError::MissingParameter("dbname"))?;
320    let database: Database = database_value.value.parse().map_err(|error| FieldError {
321        origin: database_value.origin,
322        field: Field::Database,
323        cause: FieldErrorCause::InvalidIdentifier(error),
324    })?;
325
326    // Parse sslmode, defaulting to verify-full for secure connections
327    let ssl_mode = match query_params.take("sslmode") {
328        Some(mode_str) => mode_str.parse().map_err(|_| FieldError {
329            origin: FieldSource::QueryParam,
330            field: Field::SslMode,
331            cause: FieldErrorCause::InvalidValue(mode_str.to_string()),
332        })?,
333        None => SslMode::VerifyFull,
334    };
335
336    // Parse sslrootcert
337    let ssl_root_cert = query_params.take("sslrootcert").map(|cert_str| {
338        if cert_str == "system" {
339            SslRootCert::System
340        } else {
341            SslRootCert::File(cert_str.to_string().into())
342        }
343    });
344
345    // Parse application_name
346    let application_name = match query_params.take("application_name") {
347        Some(name_str) => Some(name_str.parse().map_err(
348            |error: crate::config::ApplicationNameParseError| FieldError {
349                origin: FieldSource::QueryParam,
350                field: Field::ApplicationName,
351                cause: FieldErrorCause::InvalidValue(error.to_string()),
352            },
353        )?),
354        None => None,
355    };
356
357    if let Some(unknown) = query_params.unknown_param() {
358        return Err(ParseError::InvalidQueryParameter(unknown.to_string()));
359    }
360
361    Ok(Config {
362        endpoint,
363        session: Session {
364            application_name,
365            database,
366            password,
367            user,
368        },
369        ssl_mode,
370        ssl_root_cert,
371        #[cfg(feature = "sqlx")]
372        sqlx: Default::default(),
373    })
374}
375
376fn extract_userinfo(
377    authority: Option<&fluent_uri::component::Authority<'_>>,
378) -> Result<(Option<String>, Option<String>), ParseError> {
379    let userinfo = match authority.and_then(|authority| authority.userinfo()) {
380        Some(info) => info,
381        None => return Ok((None, None)),
382    };
383
384    match userinfo.split_once(':') {
385        Some((user_enc, pass_enc)) => {
386            let user = decode_to_string(user_enc).map_err(|error| FieldError {
387                origin: FieldSource::Authority,
388                field: Field::User,
389                cause: FieldErrorCause::InvalidUtf8(error),
390            })?;
391            let password = decode_to_string(pass_enc).map_err(|error| FieldError {
392                origin: FieldSource::Authority,
393                field: Field::Password,
394                cause: FieldErrorCause::InvalidUtf8(error),
395            })?;
396            let user = non_empty(user);
397            let password = non_empty(password);
398            Ok((user, password))
399        }
400        None => {
401            let user = decode_to_string(userinfo).map_err(|error| FieldError {
402                origin: FieldSource::Authority,
403                field: Field::User,
404                cause: FieldErrorCause::InvalidUtf8(error),
405            })?;
406            Ok((non_empty(user), None))
407        }
408    }
409}
410
411fn decode_to_string<E: fluent_uri::pct_enc::Encoder>(
412    estr: &EStr<E>,
413) -> Result<String, std::str::Utf8Error> {
414    let bytes = estr.decode().to_bytes();
415    String::from_utf8(bytes.into_owned()).map_err(|error| error.utf8_error())
416}
417
418fn non_empty(value: String) -> Option<String> {
419    if value.is_empty() { None } else { Some(value) }
420}
421
422fn decode_path_database(
423    path: &EStr<fluent_uri::pct_enc::encoder::Path>,
424) -> Result<Option<String>, ParseError> {
425    let decoded = decode_to_string(path).map_err(|error| FieldError {
426        origin: FieldSource::Path,
427        field: Field::Database,
428        cause: FieldErrorCause::InvalidUtf8(error),
429    })?;
430
431    let stripped = decoded.strip_prefix('/').unwrap_or(&decoded);
432
433    Ok(non_empty(stripped.to_string()))
434}
435
436fn parse_query(
437    query: Option<&EStr<fluent_uri::pct_enc::encoder::Query>>,
438) -> Result<BTreeMap<String, String>, ParseError> {
439    let query = match query {
440        Some(query) => query,
441        None => return Ok(BTreeMap::new()),
442    };
443
444    query
445        .split('&')
446        .map(|pair| {
447            let (key, value) = pair.split_once('=').unwrap_or((pair, EStr::EMPTY));
448            let key = decode_to_string(key).map_err(ParseError::InvalidQueryParameterEncoding)?;
449            let field = query_field(&key);
450            let value = decode_to_string(value).map_err(|error| match field {
451                Some(field) => FieldError {
452                    origin: FieldSource::QueryParam,
453                    field,
454                    cause: FieldErrorCause::InvalidUtf8(error),
455                }
456                .into(),
457                None => ParseError::InvalidQueryParameterEncoding(error),
458            })?;
459            Ok((key, value))
460        })
461        .collect()
462}
463
464fn access_field(
465    name: &'static str,
466    url_value: Option<FieldValue>,
467    query_params: &mut QueryParams<'_>,
468) -> Result<Option<FieldValue>, ParseError> {
469    let query_value = query_params
470        .take(name)
471        .map(|value| FieldValue::new(FieldSource::QueryParam, value.to_string()));
472    match (url_value, query_value) {
473        (Some(_), Some(_)) => Err(ParseError::ConflictingParameter(name)),
474        (Some(value), None) => Ok(Some(value)),
475        (None, Some(value)) => Ok(Some(value)),
476        (None, None) => Ok(None),
477    }
478}
479
480#[derive(Debug, Clone, PartialEq, Eq)]
481struct FieldValue {
482    origin: FieldSource,
483    value: String,
484}
485
486impl FieldValue {
487    fn new(origin: FieldSource, value: String) -> Self {
488        Self { origin, value }
489    }
490}
491
492fn query_field(name: &str) -> Option<Field> {
493    match name {
494        "user" => Some(Field::User),
495        "password" => Some(Field::Password),
496        "dbname" => Some(Field::Database),
497        "host" => Some(Field::Host),
498        "hostaddr" => Some(Field::HostAddr),
499        "sslmode" => Some(Field::SslMode),
500        "sslrootcert" => Some(Field::SslRootCert),
501        "application_name" => Some(Field::ApplicationName),
502        "channel_binding" => Some(Field::ChannelBinding),
503        _ => None,
504    }
505}
506
507struct QueryParams<'a> {
508    params: &'a BTreeMap<String, String>,
509    remaining: BTreeSet<&'a str>,
510}
511
512impl<'a> QueryParams<'a> {
513    fn new(params: &'a BTreeMap<String, String>) -> Self {
514        let remaining = params.keys().map(|key| key.as_str()).collect();
515        Self { params, remaining }
516    }
517
518    fn take(&mut self, name: &str) -> Option<&'a str> {
519        let value = self.params.get(name).map(|value| value.as_str());
520        if value.is_some() {
521            self.remaining.remove(name);
522        }
523        value
524    }
525
526    fn unknown_param(&self) -> Option<&&'a str> {
527        self.remaining.iter().next()
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534    use crate::config::{ChannelBinding, SslMode};
535
536    fn network(host: &str, port: Option<u16>, host_addr: Option<&str>) -> Endpoint {
537        Endpoint::Network {
538            host: host.parse().unwrap(),
539            channel_binding: None,
540            port: port.map(Port::new),
541            host_addr: host_addr.map(|address| address.parse().unwrap()),
542        }
543    }
544
545    fn success(
546        user: &str,
547        password: Option<&str>,
548        database: &str,
549        endpoint: Endpoint,
550        ssl_mode: SslMode,
551        ssl_root_cert: Option<SslRootCert>,
552        application_name: Option<&str>,
553    ) -> Config {
554        Config {
555            endpoint,
556            session: Session {
557                user: user.parse().unwrap(),
558                password: password.map(|value| value.parse().unwrap()),
559                database: database.parse().unwrap(),
560                application_name: application_name.map(|value| value.parse().unwrap()),
561            },
562            ssl_mode,
563            ssl_root_cert,
564            #[cfg(feature = "sqlx")]
565            sqlx: Default::default(),
566        }
567    }
568
569    fn field_error(origin: FieldSource, field: Field, cause: FieldErrorCause) -> ParseError {
570        ParseError::Field(FieldError {
571            origin,
572            field,
573            cause,
574        })
575    }
576
577    #[test]
578    fn test_parse() {
579        type Expected = Result<Config, ParseError>;
580
581        let cases: Vec<(&str, &str, Expected)> = vec![
582            // Success cases
583            (
584                "basic_network",
585                "postgres://user@localhost:5432/mydb",
586                Ok(success(
587                    "user",
588                    None,
589                    "mydb",
590                    network("localhost", Some(5432), None),
591                    SslMode::VerifyFull,
592                    None,
593                    None,
594                )),
595            ),
596            (
597                "with_password",
598                "postgres://user:secret@localhost/mydb",
599                Ok(success(
600                    "user",
601                    Some("secret"),
602                    "mydb",
603                    network("localhost", None, None),
604                    SslMode::VerifyFull,
605                    None,
606                    None,
607                )),
608            ),
609            (
610                "percent_encoded_password",
611                "postgres://user:p%40ss%2Fword@localhost/mydb",
612                Ok(success(
613                    "user",
614                    Some("p@ss/word"),
615                    "mydb",
616                    network("localhost", None, None),
617                    SslMode::VerifyFull,
618                    None,
619                    None,
620                )),
621            ),
622            (
623                "with_sslmode_disable",
624                "postgres://user@localhost/mydb?sslmode=disable",
625                Ok(success(
626                    "user",
627                    None,
628                    "mydb",
629                    network("localhost", None, None),
630                    SslMode::Disable,
631                    None,
632                    None,
633                )),
634            ),
635            (
636                "with_sslmode_require",
637                "postgres://user@localhost/mydb?sslmode=require",
638                Ok(success(
639                    "user",
640                    None,
641                    "mydb",
642                    network("localhost", None, None),
643                    SslMode::Require,
644                    None,
645                    None,
646                )),
647            ),
648            (
649                "with_channel_binding",
650                "postgres://user@localhost/mydb?channel_binding=require",
651                Ok(success(
652                    "user",
653                    None,
654                    "mydb",
655                    Endpoint::Network {
656                        host: "localhost".parse().unwrap(),
657                        channel_binding: Some(ChannelBinding::Require),
658                        port: None,
659                        host_addr: None,
660                    },
661                    SslMode::VerifyFull,
662                    None,
663                    None,
664                )),
665            ),
666            (
667                "with_application_name",
668                "postgres://user@localhost/mydb?application_name=myapp",
669                Ok(success(
670                    "user",
671                    None,
672                    "mydb",
673                    network("localhost", None, None),
674                    SslMode::VerifyFull,
675                    None,
676                    Some("myapp"),
677                )),
678            ),
679            (
680                "with_hostaddr",
681                "postgres://user@example.com/mydb?hostaddr=192.168.1.1",
682                Ok(success(
683                    "user",
684                    None,
685                    "mydb",
686                    network("example.com", None, Some("192.168.1.1")),
687                    SslMode::VerifyFull,
688                    None,
689                    None,
690                )),
691            ),
692            (
693                "with_sslrootcert_file",
694                "postgres://user@localhost/mydb?sslrootcert=/path/to/cert.pem",
695                Ok(success(
696                    "user",
697                    None,
698                    "mydb",
699                    network("localhost", None, None),
700                    SslMode::VerifyFull,
701                    Some(SslRootCert::File("/path/to/cert.pem".into())),
702                    None,
703                )),
704            ),
705            (
706                "with_sslrootcert_system",
707                "postgres://user@localhost/mydb?sslrootcert=system",
708                Ok(success(
709                    "user",
710                    None,
711                    "mydb",
712                    network("localhost", None, None),
713                    SslMode::VerifyFull,
714                    Some(SslRootCert::System),
715                    None,
716                )),
717            ),
718            (
719                "socket_path",
720                "postgres://?host=/var/run/postgresql&user=postgres&dbname=mydb",
721                Ok(success(
722                    "postgres",
723                    None,
724                    "mydb",
725                    Endpoint::SocketPath("/var/run/postgresql".into()),
726                    SslMode::VerifyFull,
727                    None,
728                    None,
729                )),
730            ),
731            (
732                "socket_with_password",
733                "postgres://?host=/socket&user=user&password=pass&dbname=mydb",
734                Ok(success(
735                    "user",
736                    Some("pass"),
737                    "mydb",
738                    Endpoint::SocketPath("/socket".into()),
739                    SslMode::VerifyFull,
740                    None,
741                    None,
742                )),
743            ),
744            (
745                "abstract_socket",
746                "postgres://?host=@abstract&user=postgres&dbname=mydb",
747                Ok(success(
748                    "postgres",
749                    None,
750                    "mydb",
751                    Endpoint::SocketPath("@abstract".into()),
752                    SslMode::VerifyFull,
753                    None,
754                    None,
755                )),
756            ),
757            (
758                "postgresql_scheme",
759                "postgresql://user@localhost/mydb",
760                Ok(success(
761                    "user",
762                    None,
763                    "mydb",
764                    network("localhost", None, None),
765                    SslMode::VerifyFull,
766                    None,
767                    None,
768                )),
769            ),
770            (
771                "ipv6_host",
772                "postgres://user@[::1]:5432/mydb",
773                Ok(success(
774                    "user",
775                    None,
776                    "mydb",
777                    network("::1", Some(5432), None),
778                    SslMode::VerifyFull,
779                    None,
780                    None,
781                )),
782            ),
783            (
784                "ipv4_host",
785                "postgres://user@192.168.1.1:5432/mydb",
786                Ok(success(
787                    "user",
788                    None,
789                    "mydb",
790                    network("192.168.1.1", Some(5432), None),
791                    SslMode::VerifyFull,
792                    None,
793                    None,
794                )),
795            ),
796            (
797                "no_port",
798                "postgres://user@localhost/mydb",
799                Ok(success(
800                    "user",
801                    None,
802                    "mydb",
803                    network("localhost", None, None),
804                    SslMode::VerifyFull,
805                    None,
806                    None,
807                )),
808            ),
809            // Cloud SQL success cases
810            (
811                "cloud_sql_socket",
812                "postgres://user:secret@/main?host=/cloudsql/project:region:instance",
813                Ok(success(
814                    "user",
815                    Some("secret"),
816                    "main",
817                    Endpoint::SocketPath("/cloudsql/project:region:instance".into()),
818                    SslMode::VerifyFull,
819                    None,
820                    None,
821                )),
822            ),
823            (
824                "cloud_sql_socket_no_password",
825                "postgres://user@/main?host=/cloudsql/project:region:instance",
826                Ok(success(
827                    "user",
828                    None,
829                    "main",
830                    Endpoint::SocketPath("/cloudsql/project:region:instance".into()),
831                    SslMode::VerifyFull,
832                    None,
833                    None,
834                )),
835            ),
836            (
837                "cloud_sql_socket_sslmode_disable",
838                "postgres://user:secret@/main?host=/cloudsql/project:region:instance&sslmode=disable",
839                Ok(success(
840                    "user",
841                    Some("secret"),
842                    "main",
843                    Endpoint::SocketPath("/cloudsql/project:region:instance".into()),
844                    SslMode::Disable,
845                    None,
846                    None,
847                )),
848            ),
849            (
850                "cloud_sql_socket_query_params",
851                "postgres://?host=/cloudsql/project:region:instance&user=user&password=secret&dbname=main",
852                Ok(success(
853                    "user",
854                    Some("secret"),
855                    "main",
856                    Endpoint::SocketPath("/cloudsql/project:region:instance".into()),
857                    SslMode::VerifyFull,
858                    None,
859                    None,
860                )),
861            ),
862            // Error cases
863            (
864                "invalid_scheme",
865                "mysql://user@localhost/mydb",
866                Err(ParseError::InvalidScheme("mysql".to_string())),
867            ),
868            (
869                "missing_username",
870                "postgres://localhost/mydb",
871                Err(ParseError::MissingParameter("user")),
872            ),
873            (
874                "missing_database",
875                "postgres://user@localhost",
876                Err(ParseError::MissingParameter("dbname")),
877            ),
878            (
879                "missing_host",
880                "postgres://?user=user&dbname=mydb",
881                Err(ParseError::MissingHost),
882            ),
883            (
884                "conflicting_host",
885                "postgres://user@localhost/mydb?host=/socket",
886                Err(ParseError::ConflictingParameter("host")),
887            ),
888            (
889                "conflicting_user",
890                "postgres://user@localhost/mydb?user=other",
891                Err(ParseError::ConflictingParameter("user")),
892            ),
893            (
894                "conflicting_password",
895                "postgres://user:secret@localhost/mydb?password=other",
896                Err(ParseError::ConflictingParameter("password")),
897            ),
898            (
899                "conflicting_dbname",
900                "postgres://user@localhost/mydb?dbname=other",
901                Err(ParseError::ConflictingParameter("dbname")),
902            ),
903            (
904                "invalid_sslmode",
905                "postgres://user@localhost/mydb?sslmode=invalid",
906                Err(field_error(
907                    FieldSource::QueryParam,
908                    Field::SslMode,
909                    FieldErrorCause::InvalidValue("invalid".to_string()),
910                )),
911            ),
912            (
913                "invalid_channel_binding",
914                "postgres://user@localhost/mydb?channel_binding=invalid",
915                Err(field_error(
916                    FieldSource::QueryParam,
917                    Field::ChannelBinding,
918                    FieldErrorCause::InvalidValue("invalid".to_string()),
919                )),
920            ),
921            (
922                "invalid_hostaddr",
923                "postgres://user@localhost/mydb?hostaddr=not-an-ip",
924                Err(field_error(
925                    FieldSource::QueryParam,
926                    Field::HostAddr,
927                    FieldErrorCause::InvalidValue("invalid IP address".to_string()),
928                )),
929            ),
930            (
931                "unsupported_ipvfuture_host",
932                "postgres://user@[v1.fe80]/mydb",
933                Err(field_error(
934                    FieldSource::Authority,
935                    Field::Host,
936                    FieldErrorCause::InvalidValue("unsupported host type: ipvfuture".to_string()),
937                )),
938            ),
939            (
940                "unknown_parameter",
941                "postgres://user@localhost/mydb?unknown_parameter=1",
942                Err(ParseError::InvalidQueryParameter(
943                    "unknown_parameter".to_string(),
944                )),
945            ),
946            (
947                "fragment",
948                "postgres://user@localhost/mydb#section",
949                Err(ParseError::InvalidFragment("section".to_string())),
950            ),
951            (
952                "socket_missing_user",
953                "postgres://?host=/socket&dbname=mydb",
954                Err(ParseError::MissingParameter("user")),
955            ),
956            (
957                "socket_missing_dbname",
958                "postgres://?host=/socket&user=user",
959                Err(ParseError::MissingParameter("dbname")),
960            ),
961            (
962                "socket_with_channel_binding",
963                "postgres://?host=/socket&user=user&dbname=mydb&channel_binding=require",
964                Err(ParseError::UnsupportedSocketPathParameter(
965                    "channel_binding",
966                )),
967            ),
968            (
969                "socket_with_hostaddr",
970                "postgres://?host=/socket&user=user&dbname=mydb&hostaddr=127.0.0.1",
971                Err(ParseError::UnsupportedSocketPathParameter("hostaddr")),
972            ),
973            // Cloud SQL error cases
974            (
975                "cloud_sql_conflicting_user",
976                "postgres://user@/main?host=/cloudsql/project:region:instance&user=other",
977                Err(ParseError::ConflictingParameter("user")),
978            ),
979            (
980                "cloud_sql_conflicting_password",
981                "postgres://user:secret@/main?host=/cloudsql/project:region:instance&password=other",
982                Err(ParseError::ConflictingParameter("password")),
983            ),
984            (
985                "cloud_sql_conflicting_dbname",
986                "postgres://user@/main?host=/cloudsql/project:region:instance&dbname=other",
987                Err(ParseError::ConflictingParameter("dbname")),
988            ),
989        ];
990
991        for (name, url_str, expected) in cases {
992            let actual = parse(url_str);
993
994            assert_eq!(actual, expected, "{name}: {url_str}");
995
996            if let Ok(config) = actual {
997                let roundtrip_url = config.to_url_string();
998                let roundtrip_config = parse(&roundtrip_url).unwrap_or_else(|error| {
999                    panic!("{name}: roundtrip parse failed: {error}, url: {roundtrip_url}")
1000                });
1001                assert_eq!(roundtrip_config, config, "{name}: roundtrip");
1002            }
1003        }
1004    }
1005}