1use std::collections::HashMap;
7use std::fmt;
8use std::path::PathBuf;
9
10use serde::Deserialize;
11
12use crate::error::{Result, WaypointError};
13
14macro_rules! apply_option {
18 ($opt:expr => $target:expr) => {
19 if let Some(v) = $opt {
20 $target = v;
21 }
22 };
23}
24
25macro_rules! apply_option_some {
29 ($opt:expr => $target:expr) => {
30 if let Some(v) = $opt {
31 $target = Some(v);
32 }
33 };
34}
35
36macro_rules! apply_option_clone {
40 ($opt:expr => $target:expr) => {
41 if let Some(ref v) = $opt {
42 $target = v.clone();
43 }
44 };
45}
46
47macro_rules! apply_option_some_clone {
51 ($opt:expr => $target:expr) => {
52 if let Some(ref v) = $opt {
53 $target = Some(v.clone());
54 }
55 };
56}
57
58#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
65pub enum SslMode {
66 Disable,
68 #[default]
70 Prefer,
71 Require,
73 VerifyCa,
75 VerifyFull,
77}
78
79impl SslMode {
80 pub fn verifies_certificate(&self) -> bool {
82 matches!(self, SslMode::VerifyCa | SslMode::VerifyFull)
83 }
84
85 pub fn requires_tls(&self) -> bool {
87 matches!(
88 self,
89 SslMode::Require | SslMode::VerifyCa | SslMode::VerifyFull
90 )
91 }
92
93 pub fn as_str(&self) -> &'static str {
95 match self {
96 SslMode::Disable => "disable",
97 SslMode::Prefer => "prefer",
98 SslMode::Require => "require",
99 SslMode::VerifyCa => "verify-ca",
100 SslMode::VerifyFull => "verify-full",
101 }
102 }
103}
104
105impl fmt::Display for SslMode {
106 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107 f.write_str(self.as_str())
108 }
109}
110
111impl std::str::FromStr for SslMode {
112 type Err = WaypointError;
113
114 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
115 let normalized = s.to_lowercase().replace(['-', '_'], "");
118 match normalized.as_str() {
119 "disable" | "disabled" => Ok(SslMode::Disable),
120 "prefer" => Ok(SslMode::Prefer),
121 "require" | "required" => Ok(SslMode::Require),
122 "verifyca" => Ok(SslMode::VerifyCa),
123 "verifyfull" => Ok(SslMode::VerifyFull),
124 "allow" => Err(WaypointError::ConfigError(
128 "SSL mode 'allow' is not supported. Use 'prefer' to try TLS first \
129 and fall back to plaintext."
130 .to_string(),
131 )),
132 _ => Err(WaypointError::ConfigError(format!(
133 "Invalid SSL mode '{}'. Use 'disable', 'prefer', 'require', \
134 'verify-ca', or 'verify-full'.",
135 s
136 ))),
137 }
138 }
139}
140
141fn apply_ssl_mode(target: &mut SslMode, value: &str, source: &str) {
148 match value.parse() {
149 Ok(mode) => *target = mode,
150 Err(e) => log::warn!("{} (from {}); keeping '{}'.", e, source, target),
151 }
152}
153
154fn apply_env_number<T>(target: &mut T, value: &str, var: &str)
163where
164 T: std::str::FromStr + std::fmt::Display,
165{
166 match value.parse::<T>() {
167 Ok(n) => *target = n,
168 Err(_) => log::warn!(
169 "Invalid {} '{}': expected a whole number; keeping '{}'.",
170 var,
171 value,
172 target
173 ),
174 }
175}
176
177fn parse_env_bool(value: &str, var: &str) -> Option<bool> {
185 match value.trim().to_ascii_lowercase().as_str() {
186 "1" | "true" | "yes" | "on" => Some(true),
187 "0" | "false" | "no" | "off" => Some(false),
188 other => {
189 log::warn!(
190 "Invalid {} '{}': expected one of 1/true/yes/on or 0/false/no/off; \
191 leaving the setting unchanged.",
192 var,
193 other
194 );
195 None
196 }
197 }
198}
199
200#[derive(Debug, Clone, Default)]
202pub struct WaypointConfig {
203 pub database: DatabaseConfig,
205 pub migrations: MigrationSettings,
207 pub hooks: HooksConfig,
209 pub placeholders: HashMap<String, String>,
211 pub lint: LintConfig,
213 pub snapshots: crate::commands::snapshot::SnapshotConfig,
215 pub preflight: crate::preflight::PreflightConfig,
217 pub multi_database: Option<Vec<crate::multi::NamedDatabaseConfig>>,
219 pub guards: crate::guard::GuardsConfig,
221 pub reversals: crate::reversal::ReversalConfig,
223 pub safety: crate::safety::SafetyConfig,
225 pub advisor: crate::advisor::AdvisorConfig,
227 pub simulation: SimulationConfig,
229}
230
231#[derive(Clone)]
233pub struct DatabaseConfig {
234 pub url: Option<String>,
236 pub host: Option<String>,
238 pub port: Option<u16>,
240 pub user: Option<String>,
242 pub password: Option<String>,
244 pub database: Option<String>,
246 pub connect_retries: u32,
248 pub ssl_mode: SslMode,
250 pub ssl_root_cert: Option<PathBuf>,
256 pub connect_timeout_secs: u32,
258 pub statement_timeout_secs: u32,
260 pub keepalive_secs: u32,
262 pub engine: crate::dialect::DialectKind,
268}
269
270impl Default for DatabaseConfig {
271 fn default() -> Self {
272 Self {
273 url: None,
274 host: None,
275 port: None,
276 user: None,
277 password: None,
278 database: None,
279 connect_retries: 0,
280 ssl_mode: SslMode::Prefer,
281 ssl_root_cert: None,
282 connect_timeout_secs: 30,
283 statement_timeout_secs: 0,
284 keepalive_secs: 120,
285 engine: crate::dialect::DialectKind::Postgres,
286 }
287 }
288}
289
290impl fmt::Debug for DatabaseConfig {
291 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
292 f.debug_struct("DatabaseConfig")
293 .field("url", &self.url.as_ref().map(|_| "[REDACTED]"))
294 .field("host", &self.host)
295 .field("port", &self.port)
296 .field("user", &self.user)
297 .field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
298 .field("database", &self.database)
299 .field("connect_retries", &self.connect_retries)
300 .field("ssl_mode", &self.ssl_mode)
301 .field("ssl_root_cert", &self.ssl_root_cert)
305 .field("connect_timeout_secs", &self.connect_timeout_secs)
306 .field("statement_timeout_secs", &self.statement_timeout_secs)
307 .field("keepalive_secs", &self.keepalive_secs)
308 .field("engine", &self.engine)
309 .finish()
310 }
311}
312
313#[derive(Debug, Clone, Default)]
315pub struct HooksConfig {
316 pub before_migrate: Vec<PathBuf>,
318 pub after_migrate: Vec<PathBuf>,
320 pub before_each_migrate: Vec<PathBuf>,
322 pub after_each_migrate: Vec<PathBuf>,
324}
325
326#[derive(Debug, Clone, Default)]
328pub struct LintConfig {
329 pub disabled_rules: Vec<String>,
331}
332
333#[derive(Debug, Clone)]
335pub struct MigrationSettings {
336 pub locations: Vec<PathBuf>,
338 pub table: String,
340 pub schema: String,
342 pub out_of_order: bool,
344 pub validate_on_migrate: bool,
346 pub clean_enabled: bool,
348 pub baseline_version: String,
350 pub installed_by: Option<String>,
352 pub environment: Option<String>,
354 pub dependency_ordering: bool,
356 pub show_progress: bool,
358 pub batch_transaction: bool,
360}
361
362impl Default for MigrationSettings {
363 fn default() -> Self {
364 Self {
365 locations: vec![PathBuf::from("db/migrations")],
366 table: "waypoint_schema_history".to_string(),
367 schema: "public".to_string(),
368 out_of_order: false,
369 validate_on_migrate: true,
370 clean_enabled: false,
371 baseline_version: "1".to_string(),
372 installed_by: None,
373 environment: None,
374 dependency_ordering: false,
375 show_progress: true,
376 batch_transaction: false,
377 }
378 }
379}
380
381#[derive(Debug, Clone, Default)]
383pub struct SimulationConfig {
384 pub simulate_before_migrate: bool,
386}
387
388#[derive(Deserialize, Default)]
391struct TomlConfig {
392 database: Option<TomlDatabaseConfig>,
393 migrations: Option<TomlMigrationSettings>,
394 hooks: Option<TomlHooksConfig>,
395 placeholders: Option<HashMap<String, String>>,
396 lint: Option<TomlLintConfig>,
397 snapshots: Option<TomlSnapshotConfig>,
398 preflight: Option<TomlPreflightConfig>,
399 databases: Option<Vec<TomlNamedDatabaseConfig>>,
400 guards: Option<TomlGuardsConfig>,
401 reversals: Option<TomlReversalConfig>,
402 safety: Option<TomlSafetyConfig>,
403 advisor: Option<TomlAdvisorConfig>,
404 simulation: Option<TomlSimulationConfig>,
405}
406
407#[derive(Deserialize, Default)]
408struct TomlDatabaseConfig {
409 url: Option<String>,
410 host: Option<String>,
411 port: Option<u16>,
412 user: Option<String>,
413 password: Option<String>,
414 database: Option<String>,
415 connect_retries: Option<u32>,
416 ssl_mode: Option<String>,
417 ssl_root_cert: Option<String>,
418 connect_timeout: Option<u32>,
419 statement_timeout: Option<u32>,
420 keepalive: Option<u32>,
421 engine: Option<String>,
422}
423
424#[derive(Deserialize, Default)]
425struct TomlMigrationSettings {
426 locations: Option<Vec<String>>,
427 table: Option<String>,
428 schema: Option<String>,
429 out_of_order: Option<bool>,
430 validate_on_migrate: Option<bool>,
431 clean_enabled: Option<bool>,
432 baseline_version: Option<String>,
433 installed_by: Option<String>,
434 environment: Option<String>,
435 dependency_ordering: Option<bool>,
436 show_progress: Option<bool>,
437 batch_transaction: Option<bool>,
438}
439
440#[derive(Deserialize, Default)]
441struct TomlLintConfig {
442 disabled_rules: Option<Vec<String>>,
443}
444
445#[derive(Deserialize, Default)]
446struct TomlSnapshotConfig {
447 directory: Option<String>,
448 auto_snapshot_on_migrate: Option<bool>,
449 max_snapshots: Option<usize>,
450 strip_definer_mysql: Option<bool>,
451}
452
453#[derive(Deserialize, Default)]
454struct TomlPreflightConfig {
455 enabled: Option<bool>,
456 max_replication_lag_mb: Option<i64>,
457 max_replication_lag_secs: Option<i64>,
458 long_query_threshold_secs: Option<i64>,
459}
460
461#[derive(Deserialize, Default)]
462struct TomlNamedDatabaseConfig {
463 name: Option<String>,
464 url: Option<String>,
465 depends_on: Option<Vec<String>>,
466 migrations: Option<TomlMigrationSettings>,
467 hooks: Option<TomlHooksConfig>,
468 placeholders: Option<HashMap<String, String>>,
469}
470
471#[derive(Deserialize, Default)]
472struct TomlHooksConfig {
473 before_migrate: Option<Vec<String>>,
474 after_migrate: Option<Vec<String>>,
475 before_each_migrate: Option<Vec<String>>,
476 after_each_migrate: Option<Vec<String>>,
477}
478
479#[derive(Deserialize, Default)]
480struct TomlGuardsConfig {
481 enabled: Option<bool>,
482 on_require_fail: Option<String>,
483}
484
485#[derive(Deserialize, Default)]
486struct TomlReversalConfig {
487 enabled: Option<bool>,
488 warn_data_loss: Option<bool>,
489}
490
491#[derive(Deserialize, Default)]
492struct TomlSafetyConfig {
493 enabled: Option<bool>,
494 block_on_danger: Option<bool>,
495 large_table_threshold: Option<i64>,
496 huge_table_threshold: Option<i64>,
497 refresh_stats_mysql: Option<bool>,
498}
499
500#[derive(Deserialize, Default)]
501struct TomlAdvisorConfig {
502 run_after_migrate: Option<bool>,
503 disabled_rules: Option<Vec<String>>,
504}
505
506#[derive(Deserialize, Default)]
507struct TomlSimulationConfig {
508 simulate_before_migrate: Option<bool>,
509}
510
511#[derive(Debug, Default, Clone)]
513pub struct CliOverrides {
514 pub url: Option<String>,
516 pub schema: Option<String>,
518 pub table: Option<String>,
520 pub locations: Option<Vec<PathBuf>>,
522 pub out_of_order: Option<bool>,
524 pub validate_on_migrate: Option<bool>,
526 pub baseline_version: Option<String>,
528 pub connect_retries: Option<u32>,
530 pub ssl_mode: Option<String>,
532 pub ssl_root_cert: Option<PathBuf>,
534 pub connect_timeout: Option<u32>,
536 pub statement_timeout: Option<u32>,
538 pub environment: Option<String>,
540 pub dependency_ordering: Option<bool>,
542 pub keepalive: Option<u32>,
544 pub batch_transaction: Option<bool>,
546}
547
548impl WaypointConfig {
549 pub fn load(config_path: Option<&str>, overrides: &CliOverrides) -> Result<Self> {
555 let mut config = WaypointConfig::default();
556
557 let toml_path = config_path.unwrap_or("waypoint.toml");
559 if let Ok(content) = std::fs::read_to_string(toml_path) {
560 #[cfg(unix)]
562 {
563 use std::os::unix::fs::PermissionsExt;
564 if let Ok(meta) = std::fs::metadata(toml_path) {
565 let mode = meta.permissions().mode();
566 if mode & 0o077 != 0 {
567 log::warn!(
568 "Config file has overly permissive permissions. Consider chmod 600.; path={}, mode={:o}",
569 toml_path,
570 mode
571 );
572 }
573 }
574 }
575 let toml_config: TomlConfig = toml::from_str(&content).map_err(|e| {
576 WaypointError::ConfigError(format!(
577 "Failed to parse config file '{}': {}",
578 toml_path, e
579 ))
580 })?;
581 config.apply_toml(toml_config);
582 } else if config_path.is_some() {
583 return Err(WaypointError::ConfigError(format!(
585 "Config file '{}' not found",
586 toml_path
587 )));
588 }
589
590 config.apply_env();
592
593 config.apply_cli(overrides);
595
596 crate::db::validate_identifier(&config.migrations.schema)?;
598 crate::db::validate_identifier(&config.migrations.table)?;
599
600 if config.database.connect_retries > 20 {
602 config.database.connect_retries = 20;
603 log::warn!("connect_retries capped at 20");
604 }
605
606 Ok(config)
607 }
608
609 fn apply_toml(&mut self, toml: TomlConfig) {
610 if let Some(db) = toml.database {
611 apply_option_some!(db.url => self.database.url);
612 apply_option_some!(db.host => self.database.host);
613 apply_option_some!(db.port => self.database.port);
614 apply_option_some!(db.user => self.database.user);
615 apply_option_some!(db.password => self.database.password);
616 apply_option_some!(db.database => self.database.database);
617 apply_option!(db.connect_retries => self.database.connect_retries);
618 if let Some(v) = db.ssl_mode {
619 apply_ssl_mode(&mut self.database.ssl_mode, &v, "waypoint.toml");
620 }
621 if let Some(v) = db.ssl_root_cert {
622 self.database.ssl_root_cert = Some(PathBuf::from(v));
623 }
624 apply_option!(db.connect_timeout => self.database.connect_timeout_secs);
625 apply_option!(db.statement_timeout => self.database.statement_timeout_secs);
626 apply_option!(db.keepalive => self.database.keepalive_secs);
627 if let Some(v) = db.engine {
628 match v.parse() {
629 Ok(kind) => self.database.engine = kind,
630 Err(_) => log::warn!(
631 "Invalid engine '{}' in config, using default 'postgres'. Valid values: postgres, mysql",
632 v
633 ),
634 }
635 }
636 }
637
638 if let Some(m) = toml.migrations {
639 if let Some(v) = m.locations {
640 self.migrations.locations = v.into_iter().map(|s| normalize_location(&s)).collect();
641 }
642 apply_option!(m.table => self.migrations.table);
643 apply_option!(m.schema => self.migrations.schema);
644 apply_option!(m.out_of_order => self.migrations.out_of_order);
645 apply_option!(m.validate_on_migrate => self.migrations.validate_on_migrate);
646 apply_option!(m.clean_enabled => self.migrations.clean_enabled);
647 apply_option!(m.baseline_version => self.migrations.baseline_version);
648 apply_option_some!(m.installed_by => self.migrations.installed_by);
649 apply_option_some!(m.environment => self.migrations.environment);
650 apply_option!(m.dependency_ordering => self.migrations.dependency_ordering);
651 apply_option!(m.show_progress => self.migrations.show_progress);
652 apply_option!(m.batch_transaction => self.migrations.batch_transaction);
653 }
654
655 if let Some(h) = toml.hooks {
656 if let Some(v) = h.before_migrate {
657 self.hooks.before_migrate = v.into_iter().map(PathBuf::from).collect();
658 }
659 if let Some(v) = h.after_migrate {
660 self.hooks.after_migrate = v.into_iter().map(PathBuf::from).collect();
661 }
662 if let Some(v) = h.before_each_migrate {
663 self.hooks.before_each_migrate = v.into_iter().map(PathBuf::from).collect();
664 }
665 if let Some(v) = h.after_each_migrate {
666 self.hooks.after_each_migrate = v.into_iter().map(PathBuf::from).collect();
667 }
668 }
669
670 if let Some(p) = toml.placeholders {
671 self.placeholders.extend(p);
672 }
673
674 if let Some(l) = toml.lint {
675 apply_option!(l.disabled_rules => self.lint.disabled_rules);
676 }
677
678 if let Some(s) = toml.snapshots {
679 if let Some(v) = s.directory {
680 self.snapshots.directory = PathBuf::from(v);
681 }
682 apply_option!(s.auto_snapshot_on_migrate => self.snapshots.auto_snapshot_on_migrate);
683 apply_option!(s.max_snapshots => self.snapshots.max_snapshots);
684 apply_option!(s.strip_definer_mysql => self.snapshots.strip_definer_mysql);
685 }
686
687 if let Some(p) = toml.preflight {
688 apply_option!(p.enabled => self.preflight.enabled);
689 apply_option!(p.max_replication_lag_mb => self.preflight.max_replication_lag_mb);
690 apply_option!(p.max_replication_lag_secs => self.preflight.max_replication_lag_secs);
691 apply_option!(p.long_query_threshold_secs => self.preflight.long_query_threshold_secs);
692 }
693
694 if let Some(g) = toml.guards {
695 apply_option!(g.enabled => self.guards.enabled);
696 if let Some(v) = g.on_require_fail {
697 match v.parse() {
698 Ok(policy) => self.guards.on_require_fail = policy,
699 Err(_) => log::warn!(
700 "Invalid on_require_fail '{}' in config, using default 'error'. Valid values: error, warn, skip",
701 v
702 ),
703 }
704 }
705 }
706
707 if let Some(r) = toml.reversals {
708 apply_option!(r.enabled => self.reversals.enabled);
709 apply_option!(r.warn_data_loss => self.reversals.warn_data_loss);
710 }
711
712 if let Some(s) = toml.safety {
713 apply_option!(s.enabled => self.safety.enabled);
714 apply_option!(s.block_on_danger => self.safety.block_on_danger);
715 apply_option!(s.large_table_threshold => self.safety.large_table_threshold);
716 apply_option!(s.huge_table_threshold => self.safety.huge_table_threshold);
717 apply_option!(s.refresh_stats_mysql => self.safety.refresh_stats_mysql);
718 }
719
720 if let Some(a) = toml.advisor {
721 apply_option!(a.run_after_migrate => self.advisor.run_after_migrate);
722 apply_option!(a.disabled_rules => self.advisor.disabled_rules);
723 }
724
725 if let Some(s) = toml.simulation {
726 apply_option!(s.simulate_before_migrate => self.simulation.simulate_before_migrate);
727 }
728
729 if let Some(databases) = toml.databases {
730 let mut named_dbs = Vec::new();
731 for db in databases {
732 let name = db.name.unwrap_or_default();
733 let mut db_config = DatabaseConfig {
740 url: None,
741 host: None,
742 port: None,
743 user: None,
744 password: None,
745 database: None,
746 ..self.database.clone()
747 };
748 apply_option_some!(db.url => db_config.url);
749 let env_url_key = format!("WAYPOINT_DB_{}_URL", name.to_uppercase());
751 if let Ok(url) = std::env::var(&env_url_key) {
752 db_config.url = Some(url);
753 }
754
755 let mut mig_settings = self.migrations.clone();
768 if let Some(m) = db.migrations {
769 if let Some(v) = m.locations {
770 mig_settings.locations =
771 v.into_iter().map(|s| normalize_location(&s)).collect();
772 }
773 apply_option!(m.table => mig_settings.table);
774 apply_option!(m.schema => mig_settings.schema);
775 apply_option!(m.out_of_order => mig_settings.out_of_order);
776 apply_option!(m.validate_on_migrate => mig_settings.validate_on_migrate);
777 apply_option!(m.clean_enabled => mig_settings.clean_enabled);
778 apply_option!(m.baseline_version => mig_settings.baseline_version);
779 apply_option_some!(m.installed_by => mig_settings.installed_by);
780 apply_option_some!(m.environment => mig_settings.environment);
781 apply_option!(m.dependency_ordering => mig_settings.dependency_ordering);
782 apply_option!(m.show_progress => mig_settings.show_progress);
783 apply_option!(m.batch_transaction => mig_settings.batch_transaction);
784 }
785
786 let mut hooks_config = HooksConfig::default();
787 if let Some(h) = db.hooks {
788 if let Some(v) = h.before_migrate {
789 hooks_config.before_migrate = v.into_iter().map(PathBuf::from).collect();
790 }
791 if let Some(v) = h.after_migrate {
792 hooks_config.after_migrate = v.into_iter().map(PathBuf::from).collect();
793 }
794 if let Some(v) = h.before_each_migrate {
795 hooks_config.before_each_migrate =
796 v.into_iter().map(PathBuf::from).collect();
797 }
798 if let Some(v) = h.after_each_migrate {
799 hooks_config.after_each_migrate =
800 v.into_iter().map(PathBuf::from).collect();
801 }
802 }
803
804 named_dbs.push(crate::multi::NamedDatabaseConfig {
805 name,
806 database: db_config,
807 migrations: mig_settings,
808 hooks: hooks_config,
809 placeholders: db.placeholders.unwrap_or_default(),
810 depends_on: db.depends_on.unwrap_or_default(),
811 });
812 }
813 self.multi_database = Some(named_dbs);
814 }
815 }
816
817 fn apply_env(&mut self) {
818 if let Ok(v) = std::env::var("WAYPOINT_DATABASE_URL") {
819 self.database.url = Some(v);
820 }
821 if let Ok(v) = std::env::var("WAYPOINT_DATABASE_HOST") {
822 self.database.host = Some(v);
823 }
824 if let Ok(v) = std::env::var("WAYPOINT_DATABASE_PORT") {
825 match v.parse::<u16>() {
826 Ok(port) => self.database.port = Some(port),
827 Err(_) => log::warn!(
828 "Invalid WAYPOINT_DATABASE_PORT '{}': expected a port number; \
829 keeping the existing setting.",
830 v
831 ),
832 }
833 }
834 if let Ok(v) = std::env::var("WAYPOINT_DATABASE_USER") {
835 self.database.user = Some(v);
836 }
837 if let Ok(v) = std::env::var("WAYPOINT_DATABASE_PASSWORD") {
838 self.database.password = Some(v);
839 }
840 if let Ok(v) = std::env::var("WAYPOINT_DATABASE_NAME") {
841 self.database.database = Some(v);
842 }
843 if let Ok(v) = std::env::var("WAYPOINT_CONNECT_RETRIES") {
844 apply_env_number(
845 &mut self.database.connect_retries,
846 &v,
847 "WAYPOINT_CONNECT_RETRIES",
848 );
849 }
850 if let Ok(v) = std::env::var("WAYPOINT_SSL_MODE") {
851 apply_ssl_mode(&mut self.database.ssl_mode, &v, "WAYPOINT_SSL_MODE");
852 }
853 if let Ok(v) = std::env::var("WAYPOINT_SSL_ROOT_CERT") {
854 self.database.ssl_root_cert = Some(PathBuf::from(v));
855 }
856 if let Ok(v) = std::env::var("WAYPOINT_CONNECT_TIMEOUT") {
857 apply_env_number(
858 &mut self.database.connect_timeout_secs,
859 &v,
860 "WAYPOINT_CONNECT_TIMEOUT",
861 );
862 }
863 if let Ok(v) = std::env::var("WAYPOINT_STATEMENT_TIMEOUT") {
864 apply_env_number(
865 &mut self.database.statement_timeout_secs,
866 &v,
867 "WAYPOINT_STATEMENT_TIMEOUT",
868 );
869 }
870 if let Ok(v) = std::env::var("WAYPOINT_MIGRATIONS_LOCATIONS") {
871 self.migrations.locations =
872 v.split(',').map(|s| normalize_location(s.trim())).collect();
873 }
874 if let Ok(v) = std::env::var("WAYPOINT_MIGRATIONS_TABLE") {
875 self.migrations.table = v;
876 }
877 if let Ok(v) = std::env::var("WAYPOINT_MIGRATIONS_SCHEMA") {
878 self.migrations.schema = v;
879 }
880
881 if let Ok(v) = std::env::var("WAYPOINT_DATABASE_ENGINE") {
882 match v.parse() {
883 Ok(kind) => self.database.engine = kind,
884 Err(_) => log::warn!(
885 "Invalid WAYPOINT_DATABASE_ENGINE '{}', using default 'postgres'",
886 v
887 ),
888 }
889 }
890 if let Ok(v) = std::env::var("WAYPOINT_KEEPALIVE") {
891 apply_env_number(&mut self.database.keepalive_secs, &v, "WAYPOINT_KEEPALIVE");
892 }
893 if let Ok(v) = std::env::var("WAYPOINT_BATCH_TRANSACTION")
894 && let Some(b) = parse_env_bool(&v, "WAYPOINT_BATCH_TRANSACTION")
895 {
896 self.migrations.batch_transaction = b;
897 }
898 if let Ok(v) = std::env::var("WAYPOINT_ENVIRONMENT") {
899 self.migrations.environment = Some(v);
900 }
901
902 for (key, value) in std::env::vars() {
904 if let Some(placeholder_key) = key.strip_prefix("WAYPOINT_PLACEHOLDER_") {
905 self.placeholders
906 .insert(placeholder_key.to_lowercase(), value);
907 }
908 }
909 }
910
911 fn apply_cli(&mut self, overrides: &CliOverrides) {
912 apply_option_some_clone!(overrides.url => self.database.url);
913 apply_option_clone!(overrides.schema => self.migrations.schema);
914 apply_option_clone!(overrides.table => self.migrations.table);
915 apply_option_clone!(overrides.locations => self.migrations.locations);
916 apply_option!(overrides.out_of_order => self.migrations.out_of_order);
917 apply_option!(overrides.validate_on_migrate => self.migrations.validate_on_migrate);
918 apply_option_clone!(overrides.baseline_version => self.migrations.baseline_version);
919 apply_option!(overrides.connect_retries => self.database.connect_retries);
920 if let Some(ref v) = overrides.ssl_mode {
921 apply_ssl_mode(&mut self.database.ssl_mode, v, "--ssl-mode");
922 }
923 apply_option_some_clone!(overrides.ssl_root_cert => self.database.ssl_root_cert);
924 apply_option!(overrides.connect_timeout => self.database.connect_timeout_secs);
925 apply_option!(overrides.statement_timeout => self.database.statement_timeout_secs);
926 apply_option_some_clone!(overrides.environment => self.migrations.environment);
927 apply_option!(overrides.dependency_ordering => self.migrations.dependency_ordering);
928 apply_option!(overrides.keepalive => self.database.keepalive_secs);
929 apply_option!(overrides.batch_transaction => self.migrations.batch_transaction);
930 }
931
932 pub fn connection_string(&self) -> Result<String> {
944 if let Some(ref url) = self.database.url {
945 return Ok(normalize_jdbc_url(url));
946 }
947
948 let engine = self.database.engine;
949 let host = self.database.host.as_deref().unwrap_or("localhost");
950 let default_port = match engine {
951 crate::dialect::DialectKind::Postgres => 5432,
952 crate::dialect::DialectKind::Mysql => 3306,
953 };
954 let port = self.database.port.unwrap_or(default_port);
955 let user =
956 self.database.user.as_deref().ok_or_else(|| {
957 WaypointError::ConfigError("Database user is required".to_string())
958 })?;
959 let database =
960 self.database.database.as_deref().ok_or_else(|| {
961 WaypointError::ConfigError("Database name is required".to_string())
962 })?;
963
964 match engine {
965 crate::dialect::DialectKind::Mysql => {
966 let auth = match self.database.password {
971 Some(ref password) => format!(
972 "{}:{}",
973 percent_encode_userinfo(user),
974 percent_encode_userinfo(password)
975 ),
976 None => percent_encode_userinfo(user),
977 };
978 Ok(format!("mysql://{}@{}:{}/{}", auth, host, port, database))
979 }
980 crate::dialect::DialectKind::Postgres => {
981 let mut url = format!(
982 "host={} port={} user={} dbname={}",
983 host, port, user, database
984 );
985 if let Some(ref password) = self.database.password {
986 let escaped = password.replace('\\', "\\\\").replace('\'', "\\'");
988 url.push_str(&format!(" password='{}'", escaped));
989 }
990 Ok(url)
991 }
992 }
993 }
994}
995
996fn percent_encode_userinfo(s: &str) -> String {
1003 let mut out = String::with_capacity(s.len());
1004 for b in s.bytes() {
1005 match b {
1006 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
1007 out.push(b as char)
1008 }
1009 _ => out.push_str(&format!("%{:02X}", b)),
1010 }
1011 }
1012 out
1013}
1014
1015fn normalize_jdbc_url(url: &str) -> String {
1026 let url = url.strip_prefix("jdbc:").unwrap_or(url);
1028
1029 if let Some((base, query)) = url.split_once('?') {
1031 let mut user = None;
1032 let mut password = None;
1033 let mut other_params = Vec::new();
1034
1035 for param in query.split('&') {
1036 if let Some((key, value)) = param.split_once('=') {
1037 match key.to_lowercase().as_str() {
1038 "user" => user = Some(value.to_string()),
1039 "password" => password = Some(value.to_string()),
1040 _ => other_params.push(param.to_string()),
1041 }
1042 }
1043 }
1044
1045 if (user.is_some() || password.is_some())
1047 && let Some(rest) = base
1048 .strip_prefix("postgresql://")
1049 .or_else(|| base.strip_prefix("postgres://"))
1050 {
1051 let scheme = if base.starts_with("postgresql://") {
1052 "postgresql"
1053 } else {
1054 "postgres"
1055 };
1056
1057 let auth = match (user, password) {
1058 (Some(u), Some(p)) => format!(
1059 "{}:{}@",
1060 percent_encode_userinfo(&u),
1061 percent_encode_userinfo(&p)
1062 ),
1063 (Some(u), None) => format!("{}@", percent_encode_userinfo(&u)),
1064 (None, Some(p)) => format!(":{}@", percent_encode_userinfo(&p)),
1065 (None, None) => String::new(),
1066 };
1067
1068 let mut result = format!("{}://{}{}", scheme, auth, rest);
1069 if !other_params.is_empty() {
1070 result.push('?');
1071 result.push_str(&other_params.join("&"));
1072 }
1073 return result;
1074 }
1075
1076 if other_params.is_empty() {
1078 return base.to_string();
1079 }
1080 return format!("{}?{}", base, other_params.join("&"));
1081 }
1082
1083 url.to_string()
1084}
1085
1086pub fn normalize_location(location: &str) -> PathBuf {
1088 let stripped = location.strip_prefix("filesystem:").unwrap_or(location);
1089 PathBuf::from(stripped)
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094 use super::*;
1095
1096 #[test]
1097 fn test_default_config() {
1098 let config = WaypointConfig::default();
1099 assert_eq!(config.migrations.table, "waypoint_schema_history");
1100 assert_eq!(config.migrations.schema, "public");
1101 assert!(!config.migrations.out_of_order);
1102 assert!(config.migrations.validate_on_migrate);
1103 assert!(!config.migrations.clean_enabled);
1104 assert_eq!(config.migrations.baseline_version, "1");
1105 assert_eq!(
1106 config.migrations.locations,
1107 vec![PathBuf::from("db/migrations")]
1108 );
1109 }
1110
1111 #[test]
1112 fn test_connection_string_from_url() {
1113 let mut config = WaypointConfig::default();
1114 config.database.url = Some("postgres://user:pass@localhost/db".to_string());
1115 assert_eq!(
1116 config.connection_string().unwrap(),
1117 "postgres://user:pass@localhost/db"
1118 );
1119 }
1120
1121 #[test]
1122 fn test_connection_string_from_fields() {
1123 let mut config = WaypointConfig::default();
1124 config.database.host = Some("myhost".to_string());
1125 config.database.port = Some(5433);
1126 config.database.user = Some("myuser".to_string());
1127 config.database.database = Some("mydb".to_string());
1128 config.database.password = Some("secret".to_string());
1129
1130 let conn = config.connection_string().unwrap();
1131 assert!(conn.contains("host=myhost"));
1132 assert!(conn.contains("port=5433"));
1133 assert!(conn.contains("user=myuser"));
1134 assert!(conn.contains("dbname=mydb"));
1135 assert!(conn.contains("password='secret'"));
1136 }
1137
1138 #[test]
1139 fn test_connection_string_missing_user() {
1140 let mut config = WaypointConfig::default();
1141 config.database.database = Some("mydb".to_string());
1142 assert!(config.connection_string().is_err());
1143 }
1144
1145 #[test]
1146 fn test_cli_overrides() {
1147 let mut config = WaypointConfig::default();
1148 let overrides = CliOverrides {
1149 url: Some("postgres://override@localhost/db".to_string()),
1150 schema: Some("custom_schema".to_string()),
1151 table: Some("custom_table".to_string()),
1152 locations: Some(vec![PathBuf::from("custom/path")]),
1153 out_of_order: Some(true),
1154 validate_on_migrate: Some(false),
1155 baseline_version: Some("5".to_string()),
1156 connect_retries: None,
1157 ssl_mode: None,
1158 ssl_root_cert: None,
1159 connect_timeout: None,
1160 statement_timeout: None,
1161 environment: None,
1162 dependency_ordering: None,
1163 keepalive: None,
1164 batch_transaction: None,
1165 };
1166
1167 config.apply_cli(&overrides);
1168
1169 assert_eq!(
1170 config.database.url.as_deref(),
1171 Some("postgres://override@localhost/db")
1172 );
1173 assert_eq!(config.migrations.schema, "custom_schema");
1174 assert_eq!(config.migrations.table, "custom_table");
1175 assert_eq!(
1176 config.migrations.locations,
1177 vec![PathBuf::from("custom/path")]
1178 );
1179 assert!(config.migrations.out_of_order);
1180 assert!(!config.migrations.validate_on_migrate);
1181 assert_eq!(config.migrations.baseline_version, "5");
1182 }
1183
1184 #[test]
1185 fn test_toml_parsing() {
1186 let toml_str = r#"
1187[database]
1188url = "postgres://user:pass@localhost/mydb"
1189
1190[migrations]
1191table = "my_history"
1192schema = "app"
1193out_of_order = true
1194locations = ["sql/migrations", "sql/seeds"]
1195
1196[placeholders]
1197env = "production"
1198app_name = "myapp"
1199"#;
1200
1201 let toml_config: TomlConfig = toml::from_str(toml_str).unwrap();
1202 let mut config = WaypointConfig::default();
1203 config.apply_toml(toml_config);
1204
1205 assert_eq!(
1206 config.database.url.as_deref(),
1207 Some("postgres://user:pass@localhost/mydb")
1208 );
1209 assert_eq!(config.migrations.table, "my_history");
1210 assert_eq!(config.migrations.schema, "app");
1211 assert!(config.migrations.out_of_order);
1212 assert_eq!(
1213 config.migrations.locations,
1214 vec![PathBuf::from("sql/migrations"), PathBuf::from("sql/seeds")]
1215 );
1216 assert_eq!(config.placeholders.get("env").unwrap(), "production");
1217 assert_eq!(config.placeholders.get("app_name").unwrap(), "myapp");
1218 }
1219
1220 #[test]
1221 fn test_normalize_jdbc_url_with_credentials() {
1222 let url = "jdbc:postgresql://myhost:5432/mydb?user=admin&password=secret";
1223 assert_eq!(
1224 normalize_jdbc_url(url),
1225 "postgresql://admin:secret@myhost:5432/mydb"
1226 );
1227 }
1228
1229 #[test]
1230 fn test_normalize_jdbc_url_user_only() {
1231 let url = "jdbc:postgresql://myhost:5432/mydb?user=admin";
1232 assert_eq!(
1233 normalize_jdbc_url(url),
1234 "postgresql://admin@myhost:5432/mydb"
1235 );
1236 }
1237
1238 #[test]
1239 fn test_normalize_jdbc_url_strips_jdbc_prefix() {
1240 let url = "jdbc:postgresql://myhost:5432/mydb";
1241 assert_eq!(normalize_jdbc_url(url), "postgresql://myhost:5432/mydb");
1242 }
1243
1244 #[test]
1245 fn test_normalize_jdbc_url_passthrough() {
1246 let url = "postgresql://user:pass@myhost:5432/mydb";
1247 assert_eq!(normalize_jdbc_url(url), url);
1248 }
1249
1250 #[test]
1251 fn test_normalize_jdbc_url_preserves_other_params() {
1252 let url = "jdbc:postgresql://myhost:5432/mydb?user=admin&password=secret&sslmode=require";
1253 assert_eq!(
1254 normalize_jdbc_url(url),
1255 "postgresql://admin:secret@myhost:5432/mydb?sslmode=require"
1256 );
1257 }
1258
1259 #[test]
1260 fn test_normalize_location_filesystem_prefix() {
1261 assert_eq!(
1262 normalize_location("filesystem:/flyway/sql"),
1263 PathBuf::from("/flyway/sql")
1264 );
1265 }
1266
1267 #[test]
1268 fn test_normalize_location_plain_path() {
1269 assert_eq!(
1270 normalize_location("/my/migrations"),
1271 PathBuf::from("/my/migrations")
1272 );
1273 }
1274
1275 #[test]
1276 fn test_normalize_location_relative() {
1277 assert_eq!(
1278 normalize_location("filesystem:db/migrations"),
1279 PathBuf::from("db/migrations")
1280 );
1281 }
1282
1283 #[test]
1284 fn test_connection_string_password_special_chars() {
1285 let config = WaypointConfig {
1286 database: DatabaseConfig {
1287 host: Some("localhost".to_string()),
1288 port: Some(5432),
1289 user: Some("admin".to_string()),
1290 database: Some("mydb".to_string()),
1291 password: Some("p@ss'w ord".to_string()),
1292 ..Default::default()
1293 },
1294 ..Default::default()
1295 };
1296 let conn = config.connection_string().unwrap();
1297 assert!(conn.contains("password='p@ss\\'w ord'"));
1298 }
1299
1300 #[test]
1301 fn test_connection_string_mysql_from_fields() {
1302 let config = WaypointConfig {
1303 database: DatabaseConfig {
1304 engine: crate::dialect::DialectKind::Mysql,
1305 host: Some("db.internal".to_string()),
1306 user: Some("app".to_string()),
1307 password: Some("s3cr3t".to_string()),
1308 database: Some("shop".to_string()),
1309 ..Default::default()
1310 },
1311 ..Default::default()
1312 };
1313 assert_eq!(
1315 config.connection_string().unwrap(),
1316 "mysql://app:s3cr3t@db.internal:3306/shop"
1317 );
1318 assert_eq!(
1319 crate::dialect::DialectKind::from_url(&config.connection_string().unwrap()),
1320 Some(crate::dialect::DialectKind::Mysql)
1321 );
1322 }
1323
1324 #[test]
1325 fn test_connection_string_mysql_percent_encodes_password() {
1326 let config = WaypointConfig {
1327 database: DatabaseConfig {
1328 engine: crate::dialect::DialectKind::Mysql,
1329 host: Some("h".to_string()),
1330 port: Some(13306),
1331 user: Some("u".to_string()),
1332 password: Some("p@ss/word".to_string()),
1333 database: Some("d".to_string()),
1334 ..Default::default()
1335 },
1336 ..Default::default()
1337 };
1338 assert_eq!(
1339 config.connection_string().unwrap(),
1340 "mysql://u:p%40ss%2Fword@h:13306/d"
1341 );
1342 }
1343
1344 #[test]
1345 fn test_normalize_jdbc_url_percent_encodes_credentials() {
1346 let url = "jdbc:postgresql://myhost:5432/mydb?user=adm%69n&password=p@ss";
1348 assert_eq!(
1349 normalize_jdbc_url(url),
1350 "postgresql://adm%2569n:p%40ss@myhost:5432/mydb"
1351 );
1352 }
1353
1354 #[test]
1355 fn test_engine_defaults_to_postgres() {
1356 let config = WaypointConfig::default();
1357 assert_eq!(
1358 config.database.engine,
1359 crate::dialect::DialectKind::Postgres
1360 );
1361 }
1362
1363 #[test]
1364 fn test_toml_engine_key() {
1365 let toml_str = r#"
1366[database]
1367engine = "mysql"
1368host = "localhost"
1369user = "root"
1370database = "app"
1371"#;
1372 let toml_config: TomlConfig = toml::from_str(toml_str).unwrap();
1373 let mut config = WaypointConfig::default();
1374 config.apply_toml(toml_config);
1375 assert_eq!(config.database.engine, crate::dialect::DialectKind::Mysql);
1376 assert!(config.connection_string().unwrap().starts_with("mysql://"));
1377 }
1378
1379 #[test]
1380 fn test_ssl_mode_parses_the_libpq_ladder() {
1381 for (input, want) in [
1382 ("disable", SslMode::Disable),
1383 ("DISABLE", SslMode::Disable),
1384 ("disabled", SslMode::Disable),
1385 ("prefer", SslMode::Prefer),
1386 ("require", SslMode::Require),
1387 ("required", SslMode::Require),
1388 ("verify-ca", SslMode::VerifyCa),
1389 ("verify_ca", SslMode::VerifyCa),
1390 ("verifyca", SslMode::VerifyCa),
1391 ("Verify-Full", SslMode::VerifyFull),
1392 ("verify_full", SslMode::VerifyFull),
1393 ] {
1394 assert_eq!(input.parse::<SslMode>().unwrap(), want, "input: {}", input);
1395 }
1396 }
1397
1398 #[test]
1399 fn test_ssl_mode_rejects_allow_and_names_the_alternative() {
1400 let err = "allow".parse::<SslMode>().unwrap_err().to_string();
1403 assert!(err.contains("not supported"), "got: {}", err);
1404 assert!(err.contains("prefer"), "must name the replacement: {}", err);
1405 }
1406
1407 #[test]
1408 fn test_ssl_mode_rejects_unknown_values() {
1409 assert!("verify".parse::<SslMode>().is_err());
1410 assert!("".parse::<SslMode>().is_err());
1411 let err = "banana".parse::<SslMode>().unwrap_err().to_string();
1412 assert!(
1413 err.contains("verify-full"),
1414 "should list valid modes: {}",
1415 err
1416 );
1417 }
1418
1419 #[test]
1420 fn test_ssl_mode_predicates_form_a_ladder() {
1421 use SslMode::*;
1422 let ladder = [Disable, Prefer, Require, VerifyCa, VerifyFull];
1423 assert_eq!(
1424 ladder.map(|m| m.requires_tls()),
1425 [false, false, true, true, true]
1426 );
1427 assert_eq!(
1428 ladder.map(|m| m.verifies_certificate()),
1429 [false, false, false, true, true]
1430 );
1431 }
1432
1433 #[test]
1434 fn test_ssl_root_cert_defaults_to_none() {
1435 assert_eq!(WaypointConfig::default().database.ssl_root_cert, None);
1436 }
1437
1438 #[test]
1439 fn test_toml_ssl_root_cert_key() {
1440 let toml_str = r#"
1441[database]
1442url = "postgres://user@localhost/mydb"
1443ssl_mode = "verify-full"
1444ssl_root_cert = "/etc/ssl/certs/internal-ca.pem"
1445"#;
1446 let toml_config: TomlConfig = toml::from_str(toml_str).unwrap();
1447 let mut config = WaypointConfig::default();
1448 config.apply_toml(toml_config);
1449 assert_eq!(config.database.ssl_mode, SslMode::VerifyFull);
1450 assert_eq!(
1451 config.database.ssl_root_cert,
1452 Some(PathBuf::from("/etc/ssl/certs/internal-ca.pem"))
1453 );
1454 }
1455
1456 #[test]
1457 fn test_invalid_toml_ssl_mode_keeps_the_default() {
1458 let toml_str = r#"
1459[database]
1460url = "postgres://user@localhost/mydb"
1461ssl_mode = "verify-fulll"
1462"#;
1463 let toml_config: TomlConfig = toml::from_str(toml_str).unwrap();
1464 let mut config = WaypointConfig::default();
1465 config.apply_toml(toml_config);
1466 assert_eq!(config.database.ssl_mode, SslMode::Prefer);
1467 }
1468
1469 #[test]
1470 fn test_multi_database_inherits_tls_settings() {
1471 let toml_str = r#"
1474[database]
1475ssl_mode = "verify-full"
1476ssl_root_cert = "/etc/ssl/ca.pem"
1477
1478[[databases]]
1479name = "orders"
1480url = "postgres://user@localhost/orders"
1481"#;
1482 let toml_config: TomlConfig = toml::from_str(toml_str).unwrap();
1483 let mut config = WaypointConfig::default();
1484 config.apply_toml(toml_config);
1485 let multi = config.multi_database.expect("expected a multi-db config");
1486 let db = &multi[0];
1487 assert_eq!(db.database.ssl_mode, SslMode::VerifyFull);
1488 assert_eq!(
1489 db.database.ssl_root_cert,
1490 Some(PathBuf::from("/etc/ssl/ca.pem"))
1491 );
1492 }
1493
1494 #[test]
1495 fn test_multi_database_inherits_top_level_migration_settings() {
1496 let toml_str = r#"
1502[migrations]
1503table = "custom_history"
1504schema = "app"
1505out_of_order = true
1506validate_on_migrate = false
1507
1508[[databases]]
1509name = "orders"
1510url = "postgres://user@localhost/orders"
1511
1512[databases.migrations]
1513locations = ["db/orders"]
1514"#;
1515 let toml_config: TomlConfig = toml::from_str(toml_str).unwrap();
1516 let mut config = WaypointConfig::default();
1517 config.apply_toml(toml_config);
1518 let multi = config.multi_database.expect("expected a multi-db config");
1519 let db = &multi[0];
1520
1521 assert_eq!(db.migrations.locations, vec![PathBuf::from("db/orders")]);
1523 assert_eq!(db.migrations.table, "custom_history");
1525 assert_eq!(db.migrations.schema, "app");
1526 assert!(db.migrations.out_of_order);
1527 assert!(!db.migrations.validate_on_migrate);
1528 }
1529
1530 #[test]
1531 fn test_apply_env_number_keeps_previous_value_on_garbage() {
1532 let mut timeout: u32 = 30;
1536 apply_env_number(&mut timeout, "45", "WAYPOINT_CONNECT_TIMEOUT");
1537 assert_eq!(timeout, 45, "a valid value must be applied");
1538
1539 apply_env_number(&mut timeout, "30s", "WAYPOINT_CONNECT_TIMEOUT");
1540 assert_eq!(
1541 timeout, 45,
1542 "a unit suffix must not silently reset the value"
1543 );
1544
1545 apply_env_number(&mut timeout, "", "WAYPOINT_CONNECT_TIMEOUT");
1546 assert_eq!(timeout, 45);
1547 }
1548
1549 #[test]
1550 fn test_parse_env_bool_accepts_common_spellings() {
1551 for v in ["1", "true", "TRUE", "yes", "on", " True "] {
1552 assert_eq!(parse_env_bool(v, "T"), Some(true), "input: {v:?}");
1553 }
1554 for v in ["0", "false", "FALSE", "no", "off"] {
1555 assert_eq!(parse_env_bool(v, "T"), Some(false), "input: {v:?}");
1556 }
1557 }
1558
1559 #[test]
1560 fn test_parse_env_bool_rejects_unknown_instead_of_defaulting_to_false() {
1561 for v in ["y", "enabled", "maybe", "tru"] {
1565 assert_eq!(
1566 parse_env_bool(v, "WAYPOINT_BATCH_TRANSACTION"),
1567 None,
1568 "input: {v:?}"
1569 );
1570 }
1571 }
1572}