1use std::time::Duration;
7
8use crate::transport::SslMode;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12#[non_exhaustive]
13pub enum TargetSessionAttrs {
14 #[default]
16 Any,
17 ReadWrite,
19 ReadOnly,
21}
22
23impl TargetSessionAttrs {
24 #[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#[derive(Debug, thiserror::Error)]
40#[non_exhaustive]
41pub enum ConfigError {
42 #[error("invalid value: {0}")]
44 InvalidValue(String),
45 #[error("missing required field: {0}")]
47 MissingField(String),
48 #[error("parse error: {0}")]
50 ParseError(String),
51 #[error("invalid timeout value for {field}: {value}")]
53 InvalidTimeout { field: String, value: String },
54}
55
56#[derive(Debug, Clone)]
62#[non_exhaustive]
63pub struct Config {
64 pub(crate) host: String,
66 pub(crate) port: u16,
68 pub(crate) user: String,
70 pub(crate) password: Option<String>,
72 pub(crate) database: Option<String>,
74 pub(crate) application_name: Option<String>,
76 pub(crate) ssl_mode: SslMode,
78 pub(crate) options: Vec<(String, String)>,
80 pub(crate) connect_timeout: Option<Duration>,
82 pub(crate) statement_timeout: Option<Duration>,
84 pub(crate) target_session_attrs: TargetSessionAttrs,
86 pub(crate) accept_invalid_certs: bool,
89 pub(crate) allow_insecure_auth: bool,
93 pub(crate) keepalive: Option<Duration>,
95 pub(crate) reconnect: crate::reconnect::config::ReconnectConfig,
97 pub(crate) stale: crate::reconnect::config::StaleConfig,
99}
100
101impl Config {
102 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 pub fn host(mut self, host: impl Into<String>) -> Self {
143 self.host = host.into();
144 self
145 }
146
147 pub fn port(mut self, port: u16) -> Self {
149 self.port = port;
150 self
151 }
152
153 pub fn user(mut self, user: impl Into<String>) -> Self {
155 self.user = user.into();
156 self
157 }
158
159 pub fn password(mut self, password: impl Into<String>) -> Self {
161 self.password = Some(password.into());
162 self
163 }
164
165 pub fn database(mut self, database: impl Into<String>) -> Self {
167 self.database = Some(database.into());
168 self
169 }
170
171 pub fn application_name(mut self, name: impl Into<String>) -> Self {
173 self.application_name = Some(name.into());
174 self
175 }
176
177 pub fn ssl_mode(mut self, mode: SslMode) -> Self {
179 self.ssl_mode = mode;
180 self
181 }
182
183 pub fn connect_timeout(mut self, timeout: Duration) -> Self {
185 self.connect_timeout = Some(timeout);
186 self
187 }
188
189 pub fn statement_timeout(mut self, timeout: Duration) -> Self {
191 self.statement_timeout = Some(timeout);
192 self
193 }
194
195 pub fn target_session_attrs(mut self, attrs: TargetSessionAttrs) -> Self {
197 self.target_session_attrs = attrs;
198 self
199 }
200
201 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 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 pub fn accept_invalid_certs(mut self, accept: bool) -> Self {
227 self.accept_invalid_certs = accept;
228 self
229 }
230
231 pub fn allow_insecure_auth(mut self, allow: bool) -> Self {
237 self.allow_insecure_auth = allow;
238 self
239 }
240
241 pub fn keepalive(mut self, keepalive: Duration) -> Self {
243 self.keepalive = Some(keepalive);
244 self
245 }
246
247 pub fn reconnect(mut self, config: crate::reconnect::config::ReconnectConfig) -> Self {
249 self.reconnect = config;
250 self
251 }
252
253 pub fn enable_reconnect(self) -> Self {
255 self.reconnect(crate::reconnect::config::ReconnectConfig::enabled())
256 }
257
258 pub fn max_reconnect_attempts(mut self, n: u32) -> Self {
260 self.reconnect.max_attempts = n;
261 self
262 }
263
264 pub fn stale_threshold(mut self, threshold: std::time::Duration) -> Self {
266 self.stale.stale_threshold = threshold;
267 self
268 }
269
270 pub fn stale(mut self, config: crate::reconnect::config::StaleConfig) -> Self {
272 self.stale = config;
273 self
274 }
275
276 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 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 #[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 config.host = url.host_str().unwrap_or("localhost").to_string();
380
381 config.port = url.port().unwrap_or(5432);
383
384 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 let path = url.path();
394 if path.len() > 1 {
395 config.database = Some(path[1..].to_string());
396 }
397
398 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 #[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 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 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 #[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
578fn 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#[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"); }
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}