Skip to main content

wasi_pg_client/
config.rs

1//! Connection configuration for PostgreSQL.
2//!
3//! This module defines the `Config` struct which holds parameters for connecting
4//! to a PostgreSQL server, including connection string parsing.
5
6use std::time::Duration;
7
8use crate::transport::SslMode;
9
10/// Target session attributes for connection validation.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12#[non_exhaustive]
13pub enum TargetSessionAttrs {
14    /// Any session is acceptable (default).
15    #[default]
16    Any,
17    /// The session must be read-write (reject hot standbys).
18    ReadWrite,
19    /// The session must be read-only (prefer standbys).
20    ReadOnly,
21}
22
23impl TargetSessionAttrs {
24    /// Parse from a string.
25    #[allow(clippy::should_implement_trait)]
26    pub fn from_str(s: &str) -> Result<Self, ConfigError> {
27        match s.to_lowercase().as_str() {
28            "any" => Ok(TargetSessionAttrs::Any),
29            "read-write" => Ok(TargetSessionAttrs::ReadWrite),
30            "read-only" => Ok(TargetSessionAttrs::ReadOnly),
31            _ => Err(ConfigError::InvalidValue(format!(
32                "invalid target_session_attrs: {s}"
33            ))),
34        }
35    }
36}
37
38/// Errors that can occur when building or parsing a configuration.
39#[derive(Debug, thiserror::Error)]
40#[non_exhaustive]
41pub enum ConfigError {
42    /// An invalid value was provided.
43    #[error("invalid value: {0}")]
44    InvalidValue(String),
45    /// A required field is missing.
46    #[error("missing required field: {0}")]
47    MissingField(String),
48    /// The connection string could not be parsed.
49    #[error("parse error: {0}")]
50    ParseError(String),
51    /// An invalid timeout value was provided.
52    #[error("invalid timeout value for {field}: {value}")]
53    InvalidTimeout { field: String, value: String },
54}
55
56/// Configuration for a PostgreSQL connection.
57///
58/// Use `Config::new()` to create a default configuration and then set fields
59/// using the builder methods, or parse from a connection string with
60/// `Config::from_uri` or `Config::from_key_value`.
61#[derive(Debug, Clone)]
62#[non_exhaustive]
63pub struct Config {
64    /// The hostname or IP address of the PostgreSQL server.
65    pub(crate) host: String,
66    /// The port number of the PostgreSQL server.
67    pub(crate) port: u16,
68    /// The username to authenticate with.
69    pub(crate) user: String,
70    /// The password to authenticate with.
71    pub(crate) password: Option<String>,
72    /// The database name to connect to.
73    pub(crate) database: Option<String>,
74    /// Application name reported to PostgreSQL.
75    pub(crate) application_name: Option<String>,
76    /// SSL/TLS mode.
77    pub(crate) ssl_mode: SslMode,
78    /// Extra startup parameters.
79    pub(crate) options: Vec<(String, String)>,
80    /// Connection timeout.
81    pub(crate) connect_timeout: Option<Duration>,
82    /// Statement timeout (sent as `statement_timeout` startup param).
83    pub(crate) statement_timeout: Option<Duration>,
84    /// Target session attributes.
85    pub(crate) target_session_attrs: TargetSessionAttrs,
86    /// Accept invalid/self-signed TLS certificates.
87    /// **WARNING**: Only for development/testing. Never use in production.
88    pub(crate) accept_invalid_certs: bool,
89    /// Allow legacy password authentication over plaintext transports.
90    /// **WARNING**: This weakens security and should only be used for
91    /// development or controlled environments.
92    pub(crate) allow_insecure_auth: bool,
93    /// TCP keepalive settings.
94    pub(crate) keepalive: Option<Duration>,
95    /// Reconnection policy.
96    pub(crate) reconnect: crate::reconnect::config::ReconnectConfig,
97    /// Stale connection detection.
98    pub(crate) stale: crate::reconnect::config::StaleConfig,
99}
100
101impl Config {
102    /// Creates a new configuration with default values.
103    ///
104    /// Defaults:
105    /// - host: `"localhost"`
106    /// - port: `5432`
107    /// - user: `"postgres"`
108    /// - password: `None`
109    /// - database: `None`
110    /// - ssl_mode: `VerifyFull` (if tls feature enabled) / `Disable` (otherwise)
111    /// - connect_timeout: `None`
112    /// - target_session_attrs: `Any`
113    pub fn new() -> Self {
114        Self {
115            host: "localhost".to_string(),
116            port: 5432,
117            user: "postgres".to_string(),
118            password: None,
119            database: None,
120            application_name: None,
121            #[cfg(feature = "tls")]
122            ssl_mode: SslMode::VerifyFull,
123            #[cfg(not(feature = "tls"))]
124            ssl_mode: SslMode::Disable,
125            options: Vec::new(),
126            connect_timeout: None,
127            statement_timeout: None,
128            target_session_attrs: TargetSessionAttrs::Any,
129            accept_invalid_certs: false,
130            allow_insecure_auth: false,
131            keepalive: None,
132            reconnect: crate::reconnect::config::ReconnectConfig::default(),
133            stale: crate::reconnect::config::StaleConfig::default(),
134        }
135    }
136
137    // -----------------------------------------------------------------------
138    // Builder methods
139    // -----------------------------------------------------------------------
140
141    /// Sets the hostname or IP address.
142    pub fn host(mut self, host: impl Into<String>) -> Self {
143        self.host = host.into();
144        self
145    }
146
147    /// Sets the port number.
148    pub fn port(mut self, port: u16) -> Self {
149        self.port = port;
150        self
151    }
152
153    /// Sets the username.
154    pub fn user(mut self, user: impl Into<String>) -> Self {
155        self.user = user.into();
156        self
157    }
158
159    /// Sets the password.
160    pub fn password(mut self, password: impl Into<String>) -> Self {
161        self.password = Some(password.into());
162        self
163    }
164
165    /// Sets the database name.
166    pub fn database(mut self, database: impl Into<String>) -> Self {
167        self.database = Some(database.into());
168        self
169    }
170
171    /// Sets the application name.
172    pub fn application_name(mut self, name: impl Into<String>) -> Self {
173        self.application_name = Some(name.into());
174        self
175    }
176
177    /// Sets the SSL mode.
178    pub fn ssl_mode(mut self, mode: SslMode) -> Self {
179        self.ssl_mode = mode;
180        self
181    }
182
183    /// Sets the connection timeout.
184    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
185        self.connect_timeout = Some(timeout);
186        self
187    }
188
189    /// Sets the statement timeout (sent as a startup parameter).
190    pub fn statement_timeout(mut self, timeout: Duration) -> Self {
191        self.statement_timeout = Some(timeout);
192        self
193    }
194
195    /// Sets the target session attributes.
196    pub fn target_session_attrs(mut self, attrs: TargetSessionAttrs) -> Self {
197        self.target_session_attrs = attrs;
198        self
199    }
200
201    /// Adds an extra startup option.
202    pub fn option(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
203        self.options.push((key.into(), value.into()));
204        self
205    }
206
207    /// Sets whether to use TLS (legacy alias for `ssl_mode`).
208    pub fn use_tls(mut self, use_tls: bool) -> Self {
209        if use_tls {
210            #[cfg(feature = "tls")]
211            if matches!(self.ssl_mode, SslMode::Disable) {
212                self.ssl_mode = SslMode::VerifyFull;
213            }
214            #[cfg(not(feature = "tls"))]
215            {
216                self.ssl_mode = SslMode::Disable;
217            }
218        } else {
219            self.ssl_mode = SslMode::Disable;
220        }
221        self
222    }
223
224    /// Accept invalid/self-signed TLS certificates.
225    /// **WARNING**: Only for development/testing. Never use in production.
226    pub fn accept_invalid_certs(mut self, accept: bool) -> Self {
227        self.accept_invalid_certs = accept;
228        self
229    }
230
231    /// Allow legacy password authentication over plaintext transports.
232    ///
233    /// This enables cleartext-password and MD5 authentication without TLS.
234    /// **WARNING**: Only use this in development or tightly controlled
235    /// environments.
236    pub fn allow_insecure_auth(mut self, allow: bool) -> Self {
237        self.allow_insecure_auth = allow;
238        self
239    }
240
241    /// Sets the TCP keepalive interval.
242    pub fn keepalive(mut self, keepalive: Duration) -> Self {
243        self.keepalive = Some(keepalive);
244        self
245    }
246
247    /// Sets the reconnection policy.
248    pub fn reconnect(mut self, config: crate::reconnect::config::ReconnectConfig) -> Self {
249        self.reconnect = config;
250        self
251    }
252
253    /// Enable automatic reconnection with default settings.
254    pub fn enable_reconnect(self) -> Self {
255        self.reconnect(crate::reconnect::config::ReconnectConfig::enabled())
256    }
257
258    /// Set the maximum number of reconnection attempts.
259    pub fn max_reconnect_attempts(mut self, n: u32) -> Self {
260        self.reconnect.max_attempts = n;
261        self
262    }
263
264    /// Set the stale connection detection threshold.
265    pub fn stale_threshold(mut self, threshold: std::time::Duration) -> Self {
266        self.stale.stale_threshold = threshold;
267        self
268    }
269
270    /// Sets the stale connection detection configuration.
271    pub fn stale(mut self, config: crate::reconnect::config::StaleConfig) -> Self {
272        self.stale = config;
273        self
274    }
275
276    // -----------------------------------------------------------------------
277    // Getters
278    // -----------------------------------------------------------------------
279
280    pub fn get_host(&self) -> &str {
281        &self.host
282    }
283    pub fn get_port(&self) -> u16 {
284        self.port
285    }
286    pub fn get_user(&self) -> &str {
287        &self.user
288    }
289    pub fn get_password(&self) -> Option<&str> {
290        self.password.as_deref()
291    }
292    pub fn get_database(&self) -> Option<&str> {
293        self.database.as_deref()
294    }
295    pub fn get_application_name(&self) -> Option<&str> {
296        self.application_name.as_deref()
297    }
298    pub fn get_ssl_mode(&self) -> SslMode {
299        self.ssl_mode
300    }
301    pub fn get_connect_timeout(&self) -> Option<Duration> {
302        self.connect_timeout
303    }
304    pub fn get_statement_timeout(&self) -> Option<Duration> {
305        self.statement_timeout
306    }
307    pub fn get_target_session_attrs(&self) -> TargetSessionAttrs {
308        self.target_session_attrs
309    }
310    pub fn get_use_tls(&self) -> bool {
311        !matches!(self.ssl_mode, SslMode::Disable)
312    }
313    pub fn get_accept_invalid_certs(&self) -> bool {
314        self.accept_invalid_certs
315    }
316    pub fn get_allow_insecure_auth(&self) -> bool {
317        self.allow_insecure_auth
318    }
319    pub fn get_keepalive(&self) -> Option<Duration> {
320        self.keepalive
321    }
322    pub fn get_reconnect(&self) -> &crate::reconnect::config::ReconnectConfig {
323        &self.reconnect
324    }
325    pub fn get_stale(&self) -> &crate::reconnect::config::StaleConfig {
326        &self.stale
327    }
328
329    /// Returns the startup parameters to send in the StartupMessage.
330    pub fn startup_params(&self) -> Vec<(String, String)> {
331        let mut params = vec![
332            ("user".to_string(), self.user.clone()),
333            (
334                "database".to_string(),
335                self.database.clone().unwrap_or_else(|| self.user.clone()),
336            ),
337            ("client_encoding".to_string(), "UTF8".to_string()),
338        ];
339        if let Some(ref app_name) = self.application_name {
340            params.push(("application_name".to_string(), app_name.clone()));
341        }
342        if let Some(timeout) = self.statement_timeout {
343            params.push((
344                "statement_timeout".to_string(),
345                format!("{}ms", timeout.as_millis()),
346            ));
347        }
348        for (k, v) in &self.options {
349            params.push((k.clone(), v.clone()));
350        }
351        params
352    }
353
354    // -----------------------------------------------------------------------
355    // Parsing
356    // -----------------------------------------------------------------------
357
358    /// Parse a PostgreSQL connection URI.
359    ///
360    /// Supported format:
361    /// ```text
362    /// postgresql://[user[:password]@][host][:port][/dbname][?param1=value1&...]
363    /// ```
364    #[must_use = "config parsing errors should be checked"]
365    pub fn from_uri(uri: &str) -> Result<Self, ConfigError> {
366        let url = url::Url::parse(uri)
367            .map_err(|e| ConfigError::ParseError(format!("invalid URI: {e}")))?;
368
369        if url.scheme() != "postgresql" && url.scheme() != "postgres" {
370            return Err(ConfigError::ParseError(format!(
371                "expected scheme 'postgresql' or 'postgres', got '{}'",
372                url.scheme()
373            )));
374        }
375
376        let mut config = Config::new();
377
378        // Host
379        config.host = url.host_str().unwrap_or("localhost").to_string();
380
381        // Port
382        config.port = url.port().unwrap_or(5432);
383
384        // User / Password
385        if let Some(info) = url.password() {
386            config.password = Some(info.to_string());
387        }
388        if !url.username().is_empty() {
389            config.user = url.username().to_string();
390        }
391
392        // Database (path without leading slash)
393        let path = url.path();
394        if path.len() > 1 {
395            config.database = Some(path[1..].to_string());
396        }
397
398        // Query parameters
399        for (key, value) in url.query_pairs() {
400            match key.as_ref() {
401                "sslmode" => {
402                    config.ssl_mode = SslMode::from_str(value.as_ref())
403                        .map_err(|e| ConfigError::InvalidValue(e.to_string()))?;
404                }
405                "connect_timeout" => {
406                    if let Ok(secs) = value.parse::<u64>() {
407                        config.connect_timeout = Some(Duration::from_secs(secs));
408                    } else {
409                        return Err(ConfigError::InvalidTimeout {
410                            field: "connect_timeout".into(),
411                            value: value.to_string(),
412                        });
413                    }
414                }
415                "application_name" => {
416                    config.application_name = Some(value.to_string());
417                }
418                "target_session_attrs" => {
419                    config.target_session_attrs = TargetSessionAttrs::from_str(value.as_ref())?;
420                }
421                "reconnect"
422                | "reconnect_max_attempts"
423                | "reconnect_initial_delay_ms"
424                | "reconnect_max_delay_ms"
425                | "stale_threshold_secs" => {
426                    crate::reconnect::env::parse_reconnect_params(
427                        &mut config.reconnect,
428                        &mut config.stale,
429                        key.as_ref(),
430                        value.as_ref(),
431                    )
432                    .map_err(ConfigError::InvalidValue)?;
433                }
434                _ => {
435                    config.options.push((key.to_string(), value.to_string()));
436                }
437            }
438        }
439
440        Ok(config)
441    }
442
443    /// Parse a key-value connection string.
444    ///
445    /// Format:
446    /// ```text
447    /// host=localhost port=5432 dbname=mydb user=myuser password=secret sslmode=require
448    /// ```
449    ///
450    /// Values containing spaces may be wrapped in single quotes:
451    /// ```text
452    /// host='my host' user=postgres
453    /// ```
454    #[must_use = "config parsing errors should be checked"]
455    pub fn from_key_value(s: &str) -> Result<Self, ConfigError> {
456        let mut config = Config::new();
457
458        // Simple tokenizer that respects single-quoted values.
459        let tokens = tokenize_key_value(s)?;
460
461        for token in tokens {
462            let mut parts = token.splitn(2, '=');
463            let key = parts
464                .next()
465                .ok_or_else(|| ConfigError::ParseError("empty key".into()))?;
466            let value = parts
467                .next()
468                .ok_or_else(|| ConfigError::ParseError(format!("missing value for {key}")))?;
469            // Unquote if wrapped in single quotes
470            let value = value
471                .strip_prefix('\'')
472                .and_then(|s| s.strip_suffix('\''))
473                .unwrap_or(value);
474
475            match key {
476                "host" => config.host = value.to_string(),
477                "port" => {
478                    config.port = value
479                        .parse()
480                        .map_err(|e| ConfigError::InvalidValue(format!("invalid port: {e}")))?;
481                }
482                "user" => config.user = value.to_string(),
483                "password" => config.password = Some(value.to_string()),
484                "dbname" | "database" => config.database = Some(value.to_string()),
485                "application_name" => config.application_name = Some(value.to_string()),
486                "sslmode" => {
487                    config.ssl_mode = SslMode::from_str(value)
488                        .map_err(|e| ConfigError::InvalidValue(e.to_string()))?;
489                }
490                "connect_timeout" => {
491                    if let Ok(secs) = value.parse::<u64>() {
492                        config.connect_timeout = Some(Duration::from_secs(secs));
493                    } else {
494                        return Err(ConfigError::InvalidTimeout {
495                            field: "connect_timeout".into(),
496                            value: value.to_string(),
497                        });
498                    }
499                }
500                "target_session_attrs" => {
501                    config.target_session_attrs = TargetSessionAttrs::from_str(value)?;
502                }
503                "reconnect"
504                | "reconnect_max_attempts"
505                | "reconnect_initial_delay_ms"
506                | "reconnect_max_delay_ms"
507                | "stale_threshold_secs" => {
508                    crate::reconnect::env::parse_reconnect_params(
509                        &mut config.reconnect,
510                        &mut config.stale,
511                        key,
512                        value,
513                    )
514                    .map_err(ConfigError::InvalidValue)?;
515                }
516                _ => config.options.push((key.to_string(), value.to_string())),
517            }
518        }
519
520        Ok(config)
521    }
522
523    /// Build a configuration from standard PostgreSQL environment variables.
524    ///
525    /// Variables: `PGHOST`, `PGPORT`, `PGDATABASE`, `PGUSER`, `PGPASSWORD`,
526    /// `PGSSLMODE`, `PGCONNECT_TIMEOUT`, `PGAPPNAME`.
527    #[must_use = "config parsing errors should be checked"]
528    pub fn from_env() -> Result<Self, ConfigError> {
529        let mut config = Config::new();
530
531        if let Ok(v) = std::env::var("PGHOST") {
532            config.host = v;
533        }
534        if let Ok(v) = std::env::var("PGPORT") {
535            config.port = v
536                .parse()
537                .map_err(|e| ConfigError::InvalidValue(format!("PGPORT: {e}")))?;
538        }
539        if let Ok(v) = std::env::var("PGDATABASE") {
540            config.database = Some(v);
541        }
542        if let Ok(v) = std::env::var("PGUSER") {
543            config.user = v;
544        }
545        if let Ok(v) = std::env::var("PGPASSWORD") {
546            config.password = Some(v);
547        }
548        if let Ok(v) = std::env::var("PGSSLMODE") {
549            config.ssl_mode = SslMode::from_str(&v)
550                .map_err(|e| ConfigError::InvalidValue(format!("PGSSLMODE: {e}")))?;
551        }
552        if let Ok(v) = std::env::var("PGCONNECT_TIMEOUT") {
553            if let Ok(secs) = v.parse::<u64>() {
554                config.connect_timeout = Some(Duration::from_secs(secs));
555            } else {
556                return Err(ConfigError::InvalidTimeout {
557                    field: "PGCONNECT_TIMEOUT".into(),
558                    value: v,
559                });
560            }
561        }
562        if let Ok(v) = std::env::var("PGAPPNAME") {
563            config.application_name = Some(v);
564        }
565
566        crate::reconnect::env::apply_reconnect_env(&mut config.reconnect, &mut config.stale)?;
567
568        Ok(config)
569    }
570}
571
572impl Default for Config {
573    fn default() -> Self {
574        Self::new()
575    }
576}
577
578// ---------------------------------------------------------------------------
579// Helpers
580// ---------------------------------------------------------------------------
581
582/// Tokenize a key-value string, respecting single-quoted values.
583fn tokenize_key_value(s: &str) -> Result<Vec<String>, ConfigError> {
584    let mut tokens = Vec::new();
585    let mut current = String::new();
586    let mut in_quote = false;
587    let chars = s.chars();
588
589    for ch in chars {
590        if ch == '\'' {
591            in_quote = !in_quote;
592            current.push(ch);
593        } else if ch.is_whitespace() && !in_quote {
594            if !current.is_empty() {
595                tokens.push(current.clone());
596                current.clear();
597            }
598        } else {
599            current.push(ch);
600        }
601    }
602
603    if in_quote {
604        return Err(ConfigError::ParseError(
605            "unclosed single quote in connection string".into(),
606        ));
607    }
608
609    if !current.is_empty() {
610        tokens.push(current);
611    }
612
613    Ok(tokens)
614}
615
616// ---------------------------------------------------------------------------
617// Tests
618// ---------------------------------------------------------------------------
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623
624    #[test]
625    fn test_config_builder() {
626        let config = Config::new()
627            .host("my-host")
628            .port(15432)
629            .user("my-user")
630            .password("secret")
631            .database("my-db")
632            .ssl_mode(SslMode::Require)
633            .connect_timeout(Duration::from_secs(10))
634            .application_name("test-app");
635
636        assert_eq!(config.get_host(), "my-host");
637        assert_eq!(config.get_port(), 15432);
638        assert_eq!(config.get_user(), "my-user");
639        assert_eq!(config.get_password(), Some("secret"));
640        assert_eq!(config.get_database(), Some("my-db"));
641        assert_eq!(config.get_ssl_mode(), SslMode::Require);
642        assert_eq!(config.get_connect_timeout(), Some(Duration::from_secs(10)));
643        assert_eq!(config.get_application_name(), Some("test-app"));
644    }
645
646    #[test]
647    fn test_config_startup_params() {
648        let config = Config::new()
649            .user("postgres")
650            .database("test")
651            .application_name("my-app");
652
653        let params = config.startup_params();
654        assert!(params.iter().any(|(k, v)| k == "user" && v == "postgres"));
655        assert!(params.iter().any(|(k, v)| k == "database" && v == "test"));
656        assert!(params
657            .iter()
658            .any(|(k, v)| k == "client_encoding" && v == "UTF8"));
659        assert!(params
660            .iter()
661            .any(|(k, v)| k == "application_name" && v == "my-app"));
662    }
663
664    #[test]
665    fn test_parse_uri_basic() {
666        let config =
667            Config::from_uri("postgresql://user:pass@host:1234/db?sslmode=require").unwrap();
668        assert_eq!(config.get_host(), "host");
669        assert_eq!(config.get_port(), 1234);
670        assert_eq!(config.get_user(), "user");
671        assert_eq!(config.get_password(), Some("pass"));
672        assert_eq!(config.get_database(), Some("db"));
673        assert_eq!(config.get_ssl_mode(), SslMode::Require);
674    }
675
676    #[test]
677    fn test_parse_uri_defaults() {
678        let config = Config::from_uri("postgresql://localhost").unwrap();
679        assert_eq!(config.get_host(), "localhost");
680        assert_eq!(config.get_port(), 5432);
681        assert_eq!(config.get_user(), "postgres"); // default
682    }
683
684    #[test]
685    fn test_default_ssl_mode_is_verify_full_when_tls_enabled() {
686        let config = Config::new();
687        #[cfg(feature = "tls")]
688        assert_eq!(config.get_ssl_mode(), SslMode::VerifyFull);
689        #[cfg(not(feature = "tls"))]
690        assert_eq!(config.get_ssl_mode(), SslMode::Disable);
691    }
692
693    #[test]
694    fn test_use_tls_false_forces_sslmode_disable() {
695        let config = Config::new().ssl_mode(SslMode::Require).use_tls(false);
696        assert_eq!(config.get_ssl_mode(), SslMode::Disable);
697        assert!(!config.get_use_tls());
698    }
699
700    #[test]
701    fn test_ssl_mode_disable_reports_use_tls_false() {
702        let config = Config::new().ssl_mode(SslMode::Disable);
703        assert!(!config.get_use_tls());
704    }
705
706    #[test]
707    fn test_parse_key_value() {
708        let config = Config::from_key_value(
709            "host=myhost port=5433 user=u password=p dbname=d sslmode=disable",
710        )
711        .unwrap();
712        assert_eq!(config.get_host(), "myhost");
713        assert_eq!(config.get_port(), 5433);
714        assert_eq!(config.get_user(), "u");
715        assert_eq!(config.get_password(), Some("p"));
716        assert_eq!(config.get_database(), Some("d"));
717        assert_eq!(config.get_ssl_mode(), SslMode::Disable);
718    }
719
720    #[test]
721    fn test_parse_key_value_quoted() {
722        let config = Config::from_key_value("host='my host' user=u").unwrap();
723        assert_eq!(config.get_host(), "my host");
724    }
725
726    #[test]
727    fn test_target_session_attrs_roundtrip() {
728        assert_eq!(
729            TargetSessionAttrs::from_str("read-write").unwrap(),
730            TargetSessionAttrs::ReadWrite
731        );
732        assert_eq!(
733            TargetSessionAttrs::from_str("read-only").unwrap(),
734            TargetSessionAttrs::ReadOnly
735        );
736        assert!(TargetSessionAttrs::from_str("bogus").is_err());
737    }
738
739    #[test]
740    fn test_reconnect_config() {
741        let config = Config::new()
742            .enable_reconnect()
743            .max_reconnect_attempts(5)
744            .stale_threshold(std::time::Duration::from_secs(60));
745
746        assert!(config.get_reconnect().enabled);
747        assert_eq!(config.get_reconnect().max_attempts, 5);
748        assert_eq!(
749            config.get_stale().stale_threshold,
750            std::time::Duration::from_secs(60)
751        );
752    }
753
754    #[test]
755    fn test_reconnect_from_uri() {
756        let config = Config::from_uri(
757            "postgresql://user@host/db?reconnect=true&reconnect_max_attempts=5&stale_threshold_secs=60",
758        )
759        .unwrap();
760        assert!(config.get_reconnect().enabled);
761        assert_eq!(config.get_reconnect().max_attempts, 5);
762        assert_eq!(
763            config.get_stale().stale_threshold,
764            std::time::Duration::from_secs(60)
765        );
766    }
767
768    #[test]
769    fn test_invalid_connect_timeout_in_uri_is_rejected() {
770        let err = Config::from_uri("postgresql://user@host/db?connect_timeout=abc").unwrap_err();
771        assert!(matches!(err, ConfigError::InvalidTimeout { .. }));
772    }
773
774    #[test]
775    fn test_invalid_connect_timeout_in_key_value_is_rejected() {
776        let err = Config::from_key_value("host=localhost connect_timeout=abc").unwrap_err();
777        assert!(matches!(err, ConfigError::InvalidTimeout { .. }));
778    }
779
780    #[test]
781    fn test_invalid_reconnect_param_in_uri_is_rejected() {
782        let err = Config::from_uri("postgresql://user@host/db?reconnect=maybe").unwrap_err();
783        assert!(matches!(err, ConfigError::InvalidValue(_)));
784        assert!(err.to_string().contains("invalid value for 'reconnect'"));
785    }
786
787    #[test]
788    fn test_invalid_reconnect_param_in_key_value_is_rejected() {
789        let err = Config::from_key_value("host=localhost reconnect_max_attempts=nope").unwrap_err();
790        assert!(matches!(err, ConfigError::InvalidValue(_)));
791        assert!(err
792            .to_string()
793            .contains("invalid value for 'reconnect_max_attempts'"));
794    }
795
796    #[test]
797    fn test_invalid_reconnect_env_param_is_rejected() {
798        std::env::set_var("PGRECONNECT_ATTEMPTS", "nope");
799
800        let err = Config::from_env().unwrap_err();
801        assert!(matches!(err, ConfigError::InvalidValue(_)));
802        assert!(err
803            .to_string()
804            .contains("PGRECONNECT_ATTEMPTS: expected integer"));
805
806        std::env::remove_var("PGRECONNECT_ATTEMPTS");
807    }
808}