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
154#[derive(Debug, Clone, Default)]
156pub struct WaypointConfig {
157 pub database: DatabaseConfig,
159 pub migrations: MigrationSettings,
161 pub hooks: HooksConfig,
163 pub placeholders: HashMap<String, String>,
165 pub lint: LintConfig,
167 pub snapshots: crate::commands::snapshot::SnapshotConfig,
169 pub preflight: crate::preflight::PreflightConfig,
171 pub multi_database: Option<Vec<crate::multi::NamedDatabaseConfig>>,
173 pub guards: crate::guard::GuardsConfig,
175 pub reversals: crate::reversal::ReversalConfig,
177 pub safety: crate::safety::SafetyConfig,
179 pub advisor: crate::advisor::AdvisorConfig,
181 pub simulation: SimulationConfig,
183}
184
185#[derive(Clone)]
187pub struct DatabaseConfig {
188 pub url: Option<String>,
190 pub host: Option<String>,
192 pub port: Option<u16>,
194 pub user: Option<String>,
196 pub password: Option<String>,
198 pub database: Option<String>,
200 pub connect_retries: u32,
202 pub ssl_mode: SslMode,
204 pub ssl_root_cert: Option<PathBuf>,
210 pub connect_timeout_secs: u32,
212 pub statement_timeout_secs: u32,
214 pub keepalive_secs: u32,
216 pub engine: crate::dialect::DialectKind,
222}
223
224impl Default for DatabaseConfig {
225 fn default() -> Self {
226 Self {
227 url: None,
228 host: None,
229 port: None,
230 user: None,
231 password: None,
232 database: None,
233 connect_retries: 0,
234 ssl_mode: SslMode::Prefer,
235 ssl_root_cert: None,
236 connect_timeout_secs: 30,
237 statement_timeout_secs: 0,
238 keepalive_secs: 120,
239 engine: crate::dialect::DialectKind::Postgres,
240 }
241 }
242}
243
244impl fmt::Debug for DatabaseConfig {
245 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246 f.debug_struct("DatabaseConfig")
247 .field("url", &self.url.as_ref().map(|_| "[REDACTED]"))
248 .field("host", &self.host)
249 .field("port", &self.port)
250 .field("user", &self.user)
251 .field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
252 .field("database", &self.database)
253 .field("connect_retries", &self.connect_retries)
254 .field("ssl_mode", &self.ssl_mode)
255 .field("ssl_root_cert", &self.ssl_root_cert)
259 .field("connect_timeout_secs", &self.connect_timeout_secs)
260 .field("statement_timeout_secs", &self.statement_timeout_secs)
261 .field("keepalive_secs", &self.keepalive_secs)
262 .field("engine", &self.engine)
263 .finish()
264 }
265}
266
267#[derive(Debug, Clone, Default)]
269pub struct HooksConfig {
270 pub before_migrate: Vec<PathBuf>,
272 pub after_migrate: Vec<PathBuf>,
274 pub before_each_migrate: Vec<PathBuf>,
276 pub after_each_migrate: Vec<PathBuf>,
278}
279
280#[derive(Debug, Clone, Default)]
282pub struct LintConfig {
283 pub disabled_rules: Vec<String>,
285}
286
287#[derive(Debug, Clone)]
289pub struct MigrationSettings {
290 pub locations: Vec<PathBuf>,
292 pub table: String,
294 pub schema: String,
296 pub out_of_order: bool,
298 pub validate_on_migrate: bool,
300 pub clean_enabled: bool,
302 pub baseline_version: String,
304 pub installed_by: Option<String>,
306 pub environment: Option<String>,
308 pub dependency_ordering: bool,
310 pub show_progress: bool,
312 pub batch_transaction: bool,
314}
315
316impl Default for MigrationSettings {
317 fn default() -> Self {
318 Self {
319 locations: vec![PathBuf::from("db/migrations")],
320 table: "waypoint_schema_history".to_string(),
321 schema: "public".to_string(),
322 out_of_order: false,
323 validate_on_migrate: true,
324 clean_enabled: false,
325 baseline_version: "1".to_string(),
326 installed_by: None,
327 environment: None,
328 dependency_ordering: false,
329 show_progress: true,
330 batch_transaction: false,
331 }
332 }
333}
334
335#[derive(Debug, Clone, Default)]
337pub struct SimulationConfig {
338 pub simulate_before_migrate: bool,
340}
341
342#[derive(Deserialize, Default)]
345struct TomlConfig {
346 database: Option<TomlDatabaseConfig>,
347 migrations: Option<TomlMigrationSettings>,
348 hooks: Option<TomlHooksConfig>,
349 placeholders: Option<HashMap<String, String>>,
350 lint: Option<TomlLintConfig>,
351 snapshots: Option<TomlSnapshotConfig>,
352 preflight: Option<TomlPreflightConfig>,
353 databases: Option<Vec<TomlNamedDatabaseConfig>>,
354 guards: Option<TomlGuardsConfig>,
355 reversals: Option<TomlReversalConfig>,
356 safety: Option<TomlSafetyConfig>,
357 advisor: Option<TomlAdvisorConfig>,
358 simulation: Option<TomlSimulationConfig>,
359}
360
361#[derive(Deserialize, Default)]
362struct TomlDatabaseConfig {
363 url: Option<String>,
364 host: Option<String>,
365 port: Option<u16>,
366 user: Option<String>,
367 password: Option<String>,
368 database: Option<String>,
369 connect_retries: Option<u32>,
370 ssl_mode: Option<String>,
371 ssl_root_cert: Option<String>,
372 connect_timeout: Option<u32>,
373 statement_timeout: Option<u32>,
374 keepalive: Option<u32>,
375 engine: Option<String>,
376}
377
378#[derive(Deserialize, Default)]
379struct TomlMigrationSettings {
380 locations: Option<Vec<String>>,
381 table: Option<String>,
382 schema: Option<String>,
383 out_of_order: Option<bool>,
384 validate_on_migrate: Option<bool>,
385 clean_enabled: Option<bool>,
386 baseline_version: Option<String>,
387 installed_by: Option<String>,
388 environment: Option<String>,
389 dependency_ordering: Option<bool>,
390 show_progress: Option<bool>,
391 batch_transaction: Option<bool>,
392}
393
394#[derive(Deserialize, Default)]
395struct TomlLintConfig {
396 disabled_rules: Option<Vec<String>>,
397}
398
399#[derive(Deserialize, Default)]
400struct TomlSnapshotConfig {
401 directory: Option<String>,
402 auto_snapshot_on_migrate: Option<bool>,
403 max_snapshots: Option<usize>,
404 strip_definer_mysql: Option<bool>,
405}
406
407#[derive(Deserialize, Default)]
408struct TomlPreflightConfig {
409 enabled: Option<bool>,
410 max_replication_lag_mb: Option<i64>,
411 max_replication_lag_secs: Option<i64>,
412 long_query_threshold_secs: Option<i64>,
413}
414
415#[derive(Deserialize, Default)]
416struct TomlNamedDatabaseConfig {
417 name: Option<String>,
418 url: Option<String>,
419 depends_on: Option<Vec<String>>,
420 migrations: Option<TomlMigrationSettings>,
421 hooks: Option<TomlHooksConfig>,
422 placeholders: Option<HashMap<String, String>>,
423}
424
425#[derive(Deserialize, Default)]
426struct TomlHooksConfig {
427 before_migrate: Option<Vec<String>>,
428 after_migrate: Option<Vec<String>>,
429 before_each_migrate: Option<Vec<String>>,
430 after_each_migrate: Option<Vec<String>>,
431}
432
433#[derive(Deserialize, Default)]
434struct TomlGuardsConfig {
435 enabled: Option<bool>,
436 on_require_fail: Option<String>,
437}
438
439#[derive(Deserialize, Default)]
440struct TomlReversalConfig {
441 enabled: Option<bool>,
442 warn_data_loss: Option<bool>,
443}
444
445#[derive(Deserialize, Default)]
446struct TomlSafetyConfig {
447 enabled: Option<bool>,
448 block_on_danger: Option<bool>,
449 large_table_threshold: Option<i64>,
450 huge_table_threshold: Option<i64>,
451 refresh_stats_mysql: Option<bool>,
452}
453
454#[derive(Deserialize, Default)]
455struct TomlAdvisorConfig {
456 run_after_migrate: Option<bool>,
457 disabled_rules: Option<Vec<String>>,
458}
459
460#[derive(Deserialize, Default)]
461struct TomlSimulationConfig {
462 simulate_before_migrate: Option<bool>,
463}
464
465#[derive(Debug, Default, Clone)]
467pub struct CliOverrides {
468 pub url: Option<String>,
470 pub schema: Option<String>,
472 pub table: Option<String>,
474 pub locations: Option<Vec<PathBuf>>,
476 pub out_of_order: Option<bool>,
478 pub validate_on_migrate: Option<bool>,
480 pub baseline_version: Option<String>,
482 pub connect_retries: Option<u32>,
484 pub ssl_mode: Option<String>,
486 pub ssl_root_cert: Option<PathBuf>,
488 pub connect_timeout: Option<u32>,
490 pub statement_timeout: Option<u32>,
492 pub environment: Option<String>,
494 pub dependency_ordering: Option<bool>,
496 pub keepalive: Option<u32>,
498 pub batch_transaction: Option<bool>,
500}
501
502impl WaypointConfig {
503 pub fn load(config_path: Option<&str>, overrides: &CliOverrides) -> Result<Self> {
509 let mut config = WaypointConfig::default();
510
511 let toml_path = config_path.unwrap_or("waypoint.toml");
513 if let Ok(content) = std::fs::read_to_string(toml_path) {
514 #[cfg(unix)]
516 {
517 use std::os::unix::fs::PermissionsExt;
518 if let Ok(meta) = std::fs::metadata(toml_path) {
519 let mode = meta.permissions().mode();
520 if mode & 0o077 != 0 {
521 log::warn!(
522 "Config file has overly permissive permissions. Consider chmod 600.; path={}, mode={:o}",
523 toml_path,
524 mode
525 );
526 }
527 }
528 }
529 let toml_config: TomlConfig = toml::from_str(&content).map_err(|e| {
530 WaypointError::ConfigError(format!(
531 "Failed to parse config file '{}': {}",
532 toml_path, e
533 ))
534 })?;
535 config.apply_toml(toml_config);
536 } else if config_path.is_some() {
537 return Err(WaypointError::ConfigError(format!(
539 "Config file '{}' not found",
540 toml_path
541 )));
542 }
543
544 config.apply_env();
546
547 config.apply_cli(overrides);
549
550 crate::db::validate_identifier(&config.migrations.schema)?;
552 crate::db::validate_identifier(&config.migrations.table)?;
553
554 if config.database.connect_retries > 20 {
556 config.database.connect_retries = 20;
557 log::warn!("connect_retries capped at 20");
558 }
559
560 Ok(config)
561 }
562
563 fn apply_toml(&mut self, toml: TomlConfig) {
564 if let Some(db) = toml.database {
565 apply_option_some!(db.url => self.database.url);
566 apply_option_some!(db.host => self.database.host);
567 apply_option_some!(db.port => self.database.port);
568 apply_option_some!(db.user => self.database.user);
569 apply_option_some!(db.password => self.database.password);
570 apply_option_some!(db.database => self.database.database);
571 apply_option!(db.connect_retries => self.database.connect_retries);
572 if let Some(v) = db.ssl_mode {
573 apply_ssl_mode(&mut self.database.ssl_mode, &v, "waypoint.toml");
574 }
575 if let Some(v) = db.ssl_root_cert {
576 self.database.ssl_root_cert = Some(PathBuf::from(v));
577 }
578 apply_option!(db.connect_timeout => self.database.connect_timeout_secs);
579 apply_option!(db.statement_timeout => self.database.statement_timeout_secs);
580 apply_option!(db.keepalive => self.database.keepalive_secs);
581 if let Some(v) = db.engine {
582 match v.parse() {
583 Ok(kind) => self.database.engine = kind,
584 Err(_) => log::warn!(
585 "Invalid engine '{}' in config, using default 'postgres'. Valid values: postgres, mysql",
586 v
587 ),
588 }
589 }
590 }
591
592 if let Some(m) = toml.migrations {
593 if let Some(v) = m.locations {
594 self.migrations.locations = v.into_iter().map(|s| normalize_location(&s)).collect();
595 }
596 apply_option!(m.table => self.migrations.table);
597 apply_option!(m.schema => self.migrations.schema);
598 apply_option!(m.out_of_order => self.migrations.out_of_order);
599 apply_option!(m.validate_on_migrate => self.migrations.validate_on_migrate);
600 apply_option!(m.clean_enabled => self.migrations.clean_enabled);
601 apply_option!(m.baseline_version => self.migrations.baseline_version);
602 apply_option_some!(m.installed_by => self.migrations.installed_by);
603 apply_option_some!(m.environment => self.migrations.environment);
604 apply_option!(m.dependency_ordering => self.migrations.dependency_ordering);
605 apply_option!(m.show_progress => self.migrations.show_progress);
606 apply_option!(m.batch_transaction => self.migrations.batch_transaction);
607 }
608
609 if let Some(h) = toml.hooks {
610 if let Some(v) = h.before_migrate {
611 self.hooks.before_migrate = v.into_iter().map(PathBuf::from).collect();
612 }
613 if let Some(v) = h.after_migrate {
614 self.hooks.after_migrate = v.into_iter().map(PathBuf::from).collect();
615 }
616 if let Some(v) = h.before_each_migrate {
617 self.hooks.before_each_migrate = v.into_iter().map(PathBuf::from).collect();
618 }
619 if let Some(v) = h.after_each_migrate {
620 self.hooks.after_each_migrate = v.into_iter().map(PathBuf::from).collect();
621 }
622 }
623
624 if let Some(p) = toml.placeholders {
625 self.placeholders.extend(p);
626 }
627
628 if let Some(l) = toml.lint {
629 apply_option!(l.disabled_rules => self.lint.disabled_rules);
630 }
631
632 if let Some(s) = toml.snapshots {
633 if let Some(v) = s.directory {
634 self.snapshots.directory = PathBuf::from(v);
635 }
636 apply_option!(s.auto_snapshot_on_migrate => self.snapshots.auto_snapshot_on_migrate);
637 apply_option!(s.max_snapshots => self.snapshots.max_snapshots);
638 apply_option!(s.strip_definer_mysql => self.snapshots.strip_definer_mysql);
639 }
640
641 if let Some(p) = toml.preflight {
642 apply_option!(p.enabled => self.preflight.enabled);
643 apply_option!(p.max_replication_lag_mb => self.preflight.max_replication_lag_mb);
644 apply_option!(p.max_replication_lag_secs => self.preflight.max_replication_lag_secs);
645 apply_option!(p.long_query_threshold_secs => self.preflight.long_query_threshold_secs);
646 }
647
648 if let Some(g) = toml.guards {
649 apply_option!(g.enabled => self.guards.enabled);
650 if let Some(v) = g.on_require_fail {
651 match v.parse() {
652 Ok(policy) => self.guards.on_require_fail = policy,
653 Err(_) => log::warn!(
654 "Invalid on_require_fail '{}' in config, using default 'error'. Valid values: error, warn, skip",
655 v
656 ),
657 }
658 }
659 }
660
661 if let Some(r) = toml.reversals {
662 apply_option!(r.enabled => self.reversals.enabled);
663 apply_option!(r.warn_data_loss => self.reversals.warn_data_loss);
664 }
665
666 if let Some(s) = toml.safety {
667 apply_option!(s.enabled => self.safety.enabled);
668 apply_option!(s.block_on_danger => self.safety.block_on_danger);
669 apply_option!(s.large_table_threshold => self.safety.large_table_threshold);
670 apply_option!(s.huge_table_threshold => self.safety.huge_table_threshold);
671 apply_option!(s.refresh_stats_mysql => self.safety.refresh_stats_mysql);
672 }
673
674 if let Some(a) = toml.advisor {
675 apply_option!(a.run_after_migrate => self.advisor.run_after_migrate);
676 apply_option!(a.disabled_rules => self.advisor.disabled_rules);
677 }
678
679 if let Some(s) = toml.simulation {
680 apply_option!(s.simulate_before_migrate => self.simulation.simulate_before_migrate);
681 }
682
683 if let Some(databases) = toml.databases {
684 let mut named_dbs = Vec::new();
685 for db in databases {
686 let name = db.name.unwrap_or_default();
687 let mut db_config = DatabaseConfig {
694 url: None,
695 host: None,
696 port: None,
697 user: None,
698 password: None,
699 database: None,
700 ..self.database.clone()
701 };
702 apply_option_some!(db.url => db_config.url);
703 let env_url_key = format!("WAYPOINT_DB_{}_URL", name.to_uppercase());
705 if let Ok(url) = std::env::var(&env_url_key) {
706 db_config.url = Some(url);
707 }
708
709 let mut mig_settings = MigrationSettings::default();
710 if let Some(m) = db.migrations {
711 if let Some(v) = m.locations {
712 mig_settings.locations =
713 v.into_iter().map(|s| normalize_location(&s)).collect();
714 }
715 apply_option!(m.table => mig_settings.table);
716 apply_option!(m.schema => mig_settings.schema);
717 apply_option!(m.out_of_order => mig_settings.out_of_order);
718 apply_option!(m.validate_on_migrate => mig_settings.validate_on_migrate);
719 apply_option!(m.clean_enabled => mig_settings.clean_enabled);
720 apply_option!(m.baseline_version => mig_settings.baseline_version);
721 apply_option_some!(m.installed_by => mig_settings.installed_by);
722 apply_option_some!(m.environment => mig_settings.environment);
723 apply_option!(m.dependency_ordering => mig_settings.dependency_ordering);
724 apply_option!(m.show_progress => mig_settings.show_progress);
725 apply_option!(m.batch_transaction => mig_settings.batch_transaction);
726 }
727
728 let mut hooks_config = HooksConfig::default();
729 if let Some(h) = db.hooks {
730 if let Some(v) = h.before_migrate {
731 hooks_config.before_migrate = v.into_iter().map(PathBuf::from).collect();
732 }
733 if let Some(v) = h.after_migrate {
734 hooks_config.after_migrate = v.into_iter().map(PathBuf::from).collect();
735 }
736 if let Some(v) = h.before_each_migrate {
737 hooks_config.before_each_migrate =
738 v.into_iter().map(PathBuf::from).collect();
739 }
740 if let Some(v) = h.after_each_migrate {
741 hooks_config.after_each_migrate =
742 v.into_iter().map(PathBuf::from).collect();
743 }
744 }
745
746 named_dbs.push(crate::multi::NamedDatabaseConfig {
747 name,
748 database: db_config,
749 migrations: mig_settings,
750 hooks: hooks_config,
751 placeholders: db.placeholders.unwrap_or_default(),
752 depends_on: db.depends_on.unwrap_or_default(),
753 });
754 }
755 self.multi_database = Some(named_dbs);
756 }
757 }
758
759 fn apply_env(&mut self) {
760 if let Ok(v) = std::env::var("WAYPOINT_DATABASE_URL") {
761 self.database.url = Some(v);
762 }
763 if let Ok(v) = std::env::var("WAYPOINT_DATABASE_HOST") {
764 self.database.host = Some(v);
765 }
766 if let Ok(v) = std::env::var("WAYPOINT_DATABASE_PORT")
767 && let Ok(port) = v.parse::<u16>()
768 {
769 self.database.port = Some(port);
770 }
771 if let Ok(v) = std::env::var("WAYPOINT_DATABASE_USER") {
772 self.database.user = Some(v);
773 }
774 if let Ok(v) = std::env::var("WAYPOINT_DATABASE_PASSWORD") {
775 self.database.password = Some(v);
776 }
777 if let Ok(v) = std::env::var("WAYPOINT_DATABASE_NAME") {
778 self.database.database = Some(v);
779 }
780 if let Ok(v) = std::env::var("WAYPOINT_CONNECT_RETRIES")
781 && let Ok(n) = v.parse::<u32>()
782 {
783 self.database.connect_retries = n;
784 }
785 if let Ok(v) = std::env::var("WAYPOINT_SSL_MODE") {
786 apply_ssl_mode(&mut self.database.ssl_mode, &v, "WAYPOINT_SSL_MODE");
787 }
788 if let Ok(v) = std::env::var("WAYPOINT_SSL_ROOT_CERT") {
789 self.database.ssl_root_cert = Some(PathBuf::from(v));
790 }
791 if let Ok(v) = std::env::var("WAYPOINT_CONNECT_TIMEOUT")
792 && let Ok(n) = v.parse::<u32>()
793 {
794 self.database.connect_timeout_secs = n;
795 }
796 if let Ok(v) = std::env::var("WAYPOINT_STATEMENT_TIMEOUT")
797 && let Ok(n) = v.parse::<u32>()
798 {
799 self.database.statement_timeout_secs = n;
800 }
801 if let Ok(v) = std::env::var("WAYPOINT_MIGRATIONS_LOCATIONS") {
802 self.migrations.locations =
803 v.split(',').map(|s| normalize_location(s.trim())).collect();
804 }
805 if let Ok(v) = std::env::var("WAYPOINT_MIGRATIONS_TABLE") {
806 self.migrations.table = v;
807 }
808 if let Ok(v) = std::env::var("WAYPOINT_MIGRATIONS_SCHEMA") {
809 self.migrations.schema = v;
810 }
811
812 if let Ok(v) = std::env::var("WAYPOINT_DATABASE_ENGINE") {
813 match v.parse() {
814 Ok(kind) => self.database.engine = kind,
815 Err(_) => log::warn!(
816 "Invalid WAYPOINT_DATABASE_ENGINE '{}', using default 'postgres'",
817 v
818 ),
819 }
820 }
821 if let Ok(v) = std::env::var("WAYPOINT_KEEPALIVE")
822 && let Ok(n) = v.parse::<u32>()
823 {
824 self.database.keepalive_secs = n;
825 }
826 if let Ok(v) = std::env::var("WAYPOINT_BATCH_TRANSACTION") {
827 self.migrations.batch_transaction = v == "1" || v.eq_ignore_ascii_case("true");
828 }
829 if let Ok(v) = std::env::var("WAYPOINT_ENVIRONMENT") {
830 self.migrations.environment = Some(v);
831 }
832
833 for (key, value) in std::env::vars() {
835 if let Some(placeholder_key) = key.strip_prefix("WAYPOINT_PLACEHOLDER_") {
836 self.placeholders
837 .insert(placeholder_key.to_lowercase(), value);
838 }
839 }
840 }
841
842 fn apply_cli(&mut self, overrides: &CliOverrides) {
843 apply_option_some_clone!(overrides.url => self.database.url);
844 apply_option_clone!(overrides.schema => self.migrations.schema);
845 apply_option_clone!(overrides.table => self.migrations.table);
846 apply_option_clone!(overrides.locations => self.migrations.locations);
847 apply_option!(overrides.out_of_order => self.migrations.out_of_order);
848 apply_option!(overrides.validate_on_migrate => self.migrations.validate_on_migrate);
849 apply_option_clone!(overrides.baseline_version => self.migrations.baseline_version);
850 apply_option!(overrides.connect_retries => self.database.connect_retries);
851 if let Some(ref v) = overrides.ssl_mode {
852 apply_ssl_mode(&mut self.database.ssl_mode, v, "--ssl-mode");
853 }
854 apply_option_some_clone!(overrides.ssl_root_cert => self.database.ssl_root_cert);
855 apply_option!(overrides.connect_timeout => self.database.connect_timeout_secs);
856 apply_option!(overrides.statement_timeout => self.database.statement_timeout_secs);
857 apply_option_some_clone!(overrides.environment => self.migrations.environment);
858 apply_option!(overrides.dependency_ordering => self.migrations.dependency_ordering);
859 apply_option!(overrides.keepalive => self.database.keepalive_secs);
860 apply_option!(overrides.batch_transaction => self.migrations.batch_transaction);
861 }
862
863 pub fn connection_string(&self) -> Result<String> {
875 if let Some(ref url) = self.database.url {
876 return Ok(normalize_jdbc_url(url));
877 }
878
879 let engine = self.database.engine;
880 let host = self.database.host.as_deref().unwrap_or("localhost");
881 let default_port = match engine {
882 crate::dialect::DialectKind::Postgres => 5432,
883 crate::dialect::DialectKind::Mysql => 3306,
884 };
885 let port = self.database.port.unwrap_or(default_port);
886 let user =
887 self.database.user.as_deref().ok_or_else(|| {
888 WaypointError::ConfigError("Database user is required".to_string())
889 })?;
890 let database =
891 self.database.database.as_deref().ok_or_else(|| {
892 WaypointError::ConfigError("Database name is required".to_string())
893 })?;
894
895 match engine {
896 crate::dialect::DialectKind::Mysql => {
897 let auth = match self.database.password {
902 Some(ref password) => format!(
903 "{}:{}",
904 percent_encode_userinfo(user),
905 percent_encode_userinfo(password)
906 ),
907 None => percent_encode_userinfo(user),
908 };
909 Ok(format!("mysql://{}@{}:{}/{}", auth, host, port, database))
910 }
911 crate::dialect::DialectKind::Postgres => {
912 let mut url = format!(
913 "host={} port={} user={} dbname={}",
914 host, port, user, database
915 );
916 if let Some(ref password) = self.database.password {
917 let escaped = password.replace('\\', "\\\\").replace('\'', "\\'");
919 url.push_str(&format!(" password='{}'", escaped));
920 }
921 Ok(url)
922 }
923 }
924 }
925}
926
927fn percent_encode_userinfo(s: &str) -> String {
934 let mut out = String::with_capacity(s.len());
935 for b in s.bytes() {
936 match b {
937 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
938 out.push(b as char)
939 }
940 _ => out.push_str(&format!("%{:02X}", b)),
941 }
942 }
943 out
944}
945
946fn normalize_jdbc_url(url: &str) -> String {
957 let url = url.strip_prefix("jdbc:").unwrap_or(url);
959
960 if let Some((base, query)) = url.split_once('?') {
962 let mut user = None;
963 let mut password = None;
964 let mut other_params = Vec::new();
965
966 for param in query.split('&') {
967 if let Some((key, value)) = param.split_once('=') {
968 match key.to_lowercase().as_str() {
969 "user" => user = Some(value.to_string()),
970 "password" => password = Some(value.to_string()),
971 _ => other_params.push(param.to_string()),
972 }
973 }
974 }
975
976 if (user.is_some() || password.is_some())
978 && let Some(rest) = base
979 .strip_prefix("postgresql://")
980 .or_else(|| base.strip_prefix("postgres://"))
981 {
982 let scheme = if base.starts_with("postgresql://") {
983 "postgresql"
984 } else {
985 "postgres"
986 };
987
988 let auth = match (user, password) {
989 (Some(u), Some(p)) => format!(
990 "{}:{}@",
991 percent_encode_userinfo(&u),
992 percent_encode_userinfo(&p)
993 ),
994 (Some(u), None) => format!("{}@", percent_encode_userinfo(&u)),
995 (None, Some(p)) => format!(":{}@", percent_encode_userinfo(&p)),
996 (None, None) => String::new(),
997 };
998
999 let mut result = format!("{}://{}{}", scheme, auth, rest);
1000 if !other_params.is_empty() {
1001 result.push('?');
1002 result.push_str(&other_params.join("&"));
1003 }
1004 return result;
1005 }
1006
1007 if other_params.is_empty() {
1009 return base.to_string();
1010 }
1011 return format!("{}?{}", base, other_params.join("&"));
1012 }
1013
1014 url.to_string()
1015}
1016
1017pub fn normalize_location(location: &str) -> PathBuf {
1019 let stripped = location.strip_prefix("filesystem:").unwrap_or(location);
1020 PathBuf::from(stripped)
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025 use super::*;
1026
1027 #[test]
1028 fn test_default_config() {
1029 let config = WaypointConfig::default();
1030 assert_eq!(config.migrations.table, "waypoint_schema_history");
1031 assert_eq!(config.migrations.schema, "public");
1032 assert!(!config.migrations.out_of_order);
1033 assert!(config.migrations.validate_on_migrate);
1034 assert!(!config.migrations.clean_enabled);
1035 assert_eq!(config.migrations.baseline_version, "1");
1036 assert_eq!(
1037 config.migrations.locations,
1038 vec![PathBuf::from("db/migrations")]
1039 );
1040 }
1041
1042 #[test]
1043 fn test_connection_string_from_url() {
1044 let mut config = WaypointConfig::default();
1045 config.database.url = Some("postgres://user:pass@localhost/db".to_string());
1046 assert_eq!(
1047 config.connection_string().unwrap(),
1048 "postgres://user:pass@localhost/db"
1049 );
1050 }
1051
1052 #[test]
1053 fn test_connection_string_from_fields() {
1054 let mut config = WaypointConfig::default();
1055 config.database.host = Some("myhost".to_string());
1056 config.database.port = Some(5433);
1057 config.database.user = Some("myuser".to_string());
1058 config.database.database = Some("mydb".to_string());
1059 config.database.password = Some("secret".to_string());
1060
1061 let conn = config.connection_string().unwrap();
1062 assert!(conn.contains("host=myhost"));
1063 assert!(conn.contains("port=5433"));
1064 assert!(conn.contains("user=myuser"));
1065 assert!(conn.contains("dbname=mydb"));
1066 assert!(conn.contains("password='secret'"));
1067 }
1068
1069 #[test]
1070 fn test_connection_string_missing_user() {
1071 let mut config = WaypointConfig::default();
1072 config.database.database = Some("mydb".to_string());
1073 assert!(config.connection_string().is_err());
1074 }
1075
1076 #[test]
1077 fn test_cli_overrides() {
1078 let mut config = WaypointConfig::default();
1079 let overrides = CliOverrides {
1080 url: Some("postgres://override@localhost/db".to_string()),
1081 schema: Some("custom_schema".to_string()),
1082 table: Some("custom_table".to_string()),
1083 locations: Some(vec![PathBuf::from("custom/path")]),
1084 out_of_order: Some(true),
1085 validate_on_migrate: Some(false),
1086 baseline_version: Some("5".to_string()),
1087 connect_retries: None,
1088 ssl_mode: None,
1089 ssl_root_cert: None,
1090 connect_timeout: None,
1091 statement_timeout: None,
1092 environment: None,
1093 dependency_ordering: None,
1094 keepalive: None,
1095 batch_transaction: None,
1096 };
1097
1098 config.apply_cli(&overrides);
1099
1100 assert_eq!(
1101 config.database.url.as_deref(),
1102 Some("postgres://override@localhost/db")
1103 );
1104 assert_eq!(config.migrations.schema, "custom_schema");
1105 assert_eq!(config.migrations.table, "custom_table");
1106 assert_eq!(
1107 config.migrations.locations,
1108 vec![PathBuf::from("custom/path")]
1109 );
1110 assert!(config.migrations.out_of_order);
1111 assert!(!config.migrations.validate_on_migrate);
1112 assert_eq!(config.migrations.baseline_version, "5");
1113 }
1114
1115 #[test]
1116 fn test_toml_parsing() {
1117 let toml_str = r#"
1118[database]
1119url = "postgres://user:pass@localhost/mydb"
1120
1121[migrations]
1122table = "my_history"
1123schema = "app"
1124out_of_order = true
1125locations = ["sql/migrations", "sql/seeds"]
1126
1127[placeholders]
1128env = "production"
1129app_name = "myapp"
1130"#;
1131
1132 let toml_config: TomlConfig = toml::from_str(toml_str).unwrap();
1133 let mut config = WaypointConfig::default();
1134 config.apply_toml(toml_config);
1135
1136 assert_eq!(
1137 config.database.url.as_deref(),
1138 Some("postgres://user:pass@localhost/mydb")
1139 );
1140 assert_eq!(config.migrations.table, "my_history");
1141 assert_eq!(config.migrations.schema, "app");
1142 assert!(config.migrations.out_of_order);
1143 assert_eq!(
1144 config.migrations.locations,
1145 vec![PathBuf::from("sql/migrations"), PathBuf::from("sql/seeds")]
1146 );
1147 assert_eq!(config.placeholders.get("env").unwrap(), "production");
1148 assert_eq!(config.placeholders.get("app_name").unwrap(), "myapp");
1149 }
1150
1151 #[test]
1152 fn test_normalize_jdbc_url_with_credentials() {
1153 let url = "jdbc:postgresql://myhost:5432/mydb?user=admin&password=secret";
1154 assert_eq!(
1155 normalize_jdbc_url(url),
1156 "postgresql://admin:secret@myhost:5432/mydb"
1157 );
1158 }
1159
1160 #[test]
1161 fn test_normalize_jdbc_url_user_only() {
1162 let url = "jdbc:postgresql://myhost:5432/mydb?user=admin";
1163 assert_eq!(
1164 normalize_jdbc_url(url),
1165 "postgresql://admin@myhost:5432/mydb"
1166 );
1167 }
1168
1169 #[test]
1170 fn test_normalize_jdbc_url_strips_jdbc_prefix() {
1171 let url = "jdbc:postgresql://myhost:5432/mydb";
1172 assert_eq!(normalize_jdbc_url(url), "postgresql://myhost:5432/mydb");
1173 }
1174
1175 #[test]
1176 fn test_normalize_jdbc_url_passthrough() {
1177 let url = "postgresql://user:pass@myhost:5432/mydb";
1178 assert_eq!(normalize_jdbc_url(url), url);
1179 }
1180
1181 #[test]
1182 fn test_normalize_jdbc_url_preserves_other_params() {
1183 let url = "jdbc:postgresql://myhost:5432/mydb?user=admin&password=secret&sslmode=require";
1184 assert_eq!(
1185 normalize_jdbc_url(url),
1186 "postgresql://admin:secret@myhost:5432/mydb?sslmode=require"
1187 );
1188 }
1189
1190 #[test]
1191 fn test_normalize_location_filesystem_prefix() {
1192 assert_eq!(
1193 normalize_location("filesystem:/flyway/sql"),
1194 PathBuf::from("/flyway/sql")
1195 );
1196 }
1197
1198 #[test]
1199 fn test_normalize_location_plain_path() {
1200 assert_eq!(
1201 normalize_location("/my/migrations"),
1202 PathBuf::from("/my/migrations")
1203 );
1204 }
1205
1206 #[test]
1207 fn test_normalize_location_relative() {
1208 assert_eq!(
1209 normalize_location("filesystem:db/migrations"),
1210 PathBuf::from("db/migrations")
1211 );
1212 }
1213
1214 #[test]
1215 fn test_connection_string_password_special_chars() {
1216 let config = WaypointConfig {
1217 database: DatabaseConfig {
1218 host: Some("localhost".to_string()),
1219 port: Some(5432),
1220 user: Some("admin".to_string()),
1221 database: Some("mydb".to_string()),
1222 password: Some("p@ss'w ord".to_string()),
1223 ..Default::default()
1224 },
1225 ..Default::default()
1226 };
1227 let conn = config.connection_string().unwrap();
1228 assert!(conn.contains("password='p@ss\\'w ord'"));
1229 }
1230
1231 #[test]
1232 fn test_connection_string_mysql_from_fields() {
1233 let config = WaypointConfig {
1234 database: DatabaseConfig {
1235 engine: crate::dialect::DialectKind::Mysql,
1236 host: Some("db.internal".to_string()),
1237 user: Some("app".to_string()),
1238 password: Some("s3cr3t".to_string()),
1239 database: Some("shop".to_string()),
1240 ..Default::default()
1241 },
1242 ..Default::default()
1243 };
1244 assert_eq!(
1246 config.connection_string().unwrap(),
1247 "mysql://app:s3cr3t@db.internal:3306/shop"
1248 );
1249 assert_eq!(
1250 crate::dialect::DialectKind::from_url(&config.connection_string().unwrap()),
1251 Some(crate::dialect::DialectKind::Mysql)
1252 );
1253 }
1254
1255 #[test]
1256 fn test_connection_string_mysql_percent_encodes_password() {
1257 let config = WaypointConfig {
1258 database: DatabaseConfig {
1259 engine: crate::dialect::DialectKind::Mysql,
1260 host: Some("h".to_string()),
1261 port: Some(13306),
1262 user: Some("u".to_string()),
1263 password: Some("p@ss/word".to_string()),
1264 database: Some("d".to_string()),
1265 ..Default::default()
1266 },
1267 ..Default::default()
1268 };
1269 assert_eq!(
1270 config.connection_string().unwrap(),
1271 "mysql://u:p%40ss%2Fword@h:13306/d"
1272 );
1273 }
1274
1275 #[test]
1276 fn test_normalize_jdbc_url_percent_encodes_credentials() {
1277 let url = "jdbc:postgresql://myhost:5432/mydb?user=adm%69n&password=p@ss";
1279 assert_eq!(
1280 normalize_jdbc_url(url),
1281 "postgresql://adm%2569n:p%40ss@myhost:5432/mydb"
1282 );
1283 }
1284
1285 #[test]
1286 fn test_engine_defaults_to_postgres() {
1287 let config = WaypointConfig::default();
1288 assert_eq!(
1289 config.database.engine,
1290 crate::dialect::DialectKind::Postgres
1291 );
1292 }
1293
1294 #[test]
1295 fn test_toml_engine_key() {
1296 let toml_str = r#"
1297[database]
1298engine = "mysql"
1299host = "localhost"
1300user = "root"
1301database = "app"
1302"#;
1303 let toml_config: TomlConfig = toml::from_str(toml_str).unwrap();
1304 let mut config = WaypointConfig::default();
1305 config.apply_toml(toml_config);
1306 assert_eq!(config.database.engine, crate::dialect::DialectKind::Mysql);
1307 assert!(config.connection_string().unwrap().starts_with("mysql://"));
1308 }
1309
1310 #[test]
1311 fn test_ssl_mode_parses_the_libpq_ladder() {
1312 for (input, want) in [
1313 ("disable", SslMode::Disable),
1314 ("DISABLE", SslMode::Disable),
1315 ("disabled", SslMode::Disable),
1316 ("prefer", SslMode::Prefer),
1317 ("require", SslMode::Require),
1318 ("required", SslMode::Require),
1319 ("verify-ca", SslMode::VerifyCa),
1320 ("verify_ca", SslMode::VerifyCa),
1321 ("verifyca", SslMode::VerifyCa),
1322 ("Verify-Full", SslMode::VerifyFull),
1323 ("verify_full", SslMode::VerifyFull),
1324 ] {
1325 assert_eq!(input.parse::<SslMode>().unwrap(), want, "input: {}", input);
1326 }
1327 }
1328
1329 #[test]
1330 fn test_ssl_mode_rejects_allow_and_names_the_alternative() {
1331 let err = "allow".parse::<SslMode>().unwrap_err().to_string();
1334 assert!(err.contains("not supported"), "got: {}", err);
1335 assert!(err.contains("prefer"), "must name the replacement: {}", err);
1336 }
1337
1338 #[test]
1339 fn test_ssl_mode_rejects_unknown_values() {
1340 assert!("verify".parse::<SslMode>().is_err());
1341 assert!("".parse::<SslMode>().is_err());
1342 let err = "banana".parse::<SslMode>().unwrap_err().to_string();
1343 assert!(
1344 err.contains("verify-full"),
1345 "should list valid modes: {}",
1346 err
1347 );
1348 }
1349
1350 #[test]
1351 fn test_ssl_mode_predicates_form_a_ladder() {
1352 use SslMode::*;
1353 let ladder = [Disable, Prefer, Require, VerifyCa, VerifyFull];
1354 assert_eq!(
1355 ladder.map(|m| m.requires_tls()),
1356 [false, false, true, true, true]
1357 );
1358 assert_eq!(
1359 ladder.map(|m| m.verifies_certificate()),
1360 [false, false, false, true, true]
1361 );
1362 }
1363
1364 #[test]
1365 fn test_ssl_root_cert_defaults_to_none() {
1366 assert_eq!(WaypointConfig::default().database.ssl_root_cert, None);
1367 }
1368
1369 #[test]
1370 fn test_toml_ssl_root_cert_key() {
1371 let toml_str = r#"
1372[database]
1373url = "postgres://user@localhost/mydb"
1374ssl_mode = "verify-full"
1375ssl_root_cert = "/etc/ssl/certs/internal-ca.pem"
1376"#;
1377 let toml_config: TomlConfig = toml::from_str(toml_str).unwrap();
1378 let mut config = WaypointConfig::default();
1379 config.apply_toml(toml_config);
1380 assert_eq!(config.database.ssl_mode, SslMode::VerifyFull);
1381 assert_eq!(
1382 config.database.ssl_root_cert,
1383 Some(PathBuf::from("/etc/ssl/certs/internal-ca.pem"))
1384 );
1385 }
1386
1387 #[test]
1388 fn test_invalid_toml_ssl_mode_keeps_the_default() {
1389 let toml_str = r#"
1390[database]
1391url = "postgres://user@localhost/mydb"
1392ssl_mode = "verify-fulll"
1393"#;
1394 let toml_config: TomlConfig = toml::from_str(toml_str).unwrap();
1395 let mut config = WaypointConfig::default();
1396 config.apply_toml(toml_config);
1397 assert_eq!(config.database.ssl_mode, SslMode::Prefer);
1398 }
1399
1400 #[test]
1401 fn test_multi_database_inherits_tls_settings() {
1402 let toml_str = r#"
1405[database]
1406ssl_mode = "verify-full"
1407ssl_root_cert = "/etc/ssl/ca.pem"
1408
1409[[databases]]
1410name = "orders"
1411url = "postgres://user@localhost/orders"
1412"#;
1413 let toml_config: TomlConfig = toml::from_str(toml_str).unwrap();
1414 let mut config = WaypointConfig::default();
1415 config.apply_toml(toml_config);
1416 let multi = config.multi_database.expect("expected a multi-db config");
1417 let db = &multi[0];
1418 assert_eq!(db.database.ssl_mode, SslMode::VerifyFull);
1419 assert_eq!(
1420 db.database.ssl_root_cert,
1421 Some(PathBuf::from("/etc/ssl/ca.pem"))
1422 );
1423 }
1424}