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, Default, PartialEq, Eq)]
60pub enum SslMode {
61 Disable,
63 #[default]
65 Prefer,
66 Require,
68}
69
70impl std::str::FromStr for SslMode {
71 type Err = WaypointError;
72
73 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
74 match s.to_lowercase().as_str() {
75 "disable" | "disabled" => Ok(SslMode::Disable),
76 "prefer" => Ok(SslMode::Prefer),
77 "require" | "required" => Ok(SslMode::Require),
78 _ => Err(WaypointError::ConfigError(format!(
79 "Invalid SSL mode '{}'. Use 'disable', 'prefer', or 'require'.",
80 s
81 ))),
82 }
83 }
84}
85
86#[derive(Debug, Clone, Default)]
88pub struct WaypointConfig {
89 pub database: DatabaseConfig,
91 pub migrations: MigrationSettings,
93 pub hooks: HooksConfig,
95 pub placeholders: HashMap<String, String>,
97 pub lint: LintConfig,
99 pub snapshots: crate::commands::snapshot::SnapshotConfig,
101 pub preflight: crate::preflight::PreflightConfig,
103 pub multi_database: Option<Vec<crate::multi::NamedDatabaseConfig>>,
105 pub guards: crate::guard::GuardsConfig,
107 pub reversals: crate::reversal::ReversalConfig,
109 pub safety: crate::safety::SafetyConfig,
111 pub advisor: crate::advisor::AdvisorConfig,
113 pub simulation: SimulationConfig,
115}
116
117#[derive(Clone)]
119pub struct DatabaseConfig {
120 pub url: Option<String>,
122 pub host: Option<String>,
124 pub port: Option<u16>,
126 pub user: Option<String>,
128 pub password: Option<String>,
130 pub database: Option<String>,
132 pub connect_retries: u32,
134 pub ssl_mode: SslMode,
136 pub connect_timeout_secs: u32,
138 pub statement_timeout_secs: u32,
140 pub keepalive_secs: u32,
142}
143
144impl Default for DatabaseConfig {
145 fn default() -> Self {
146 Self {
147 url: None,
148 host: None,
149 port: None,
150 user: None,
151 password: None,
152 database: None,
153 connect_retries: 0,
154 ssl_mode: SslMode::Prefer,
155 connect_timeout_secs: 30,
156 statement_timeout_secs: 0,
157 keepalive_secs: 120,
158 }
159 }
160}
161
162impl fmt::Debug for DatabaseConfig {
163 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164 f.debug_struct("DatabaseConfig")
165 .field("url", &self.url.as_ref().map(|_| "[REDACTED]"))
166 .field("host", &self.host)
167 .field("port", &self.port)
168 .field("user", &self.user)
169 .field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
170 .field("database", &self.database)
171 .field("connect_retries", &self.connect_retries)
172 .field("ssl_mode", &self.ssl_mode)
173 .field("connect_timeout_secs", &self.connect_timeout_secs)
174 .field("statement_timeout_secs", &self.statement_timeout_secs)
175 .field("keepalive_secs", &self.keepalive_secs)
176 .finish()
177 }
178}
179
180#[derive(Debug, Clone, Default)]
182pub struct HooksConfig {
183 pub before_migrate: Vec<PathBuf>,
185 pub after_migrate: Vec<PathBuf>,
187 pub before_each_migrate: Vec<PathBuf>,
189 pub after_each_migrate: Vec<PathBuf>,
191}
192
193#[derive(Debug, Clone, Default)]
195pub struct LintConfig {
196 pub disabled_rules: Vec<String>,
198}
199
200#[derive(Debug, Clone)]
202pub struct MigrationSettings {
203 pub locations: Vec<PathBuf>,
205 pub table: String,
207 pub schema: String,
209 pub out_of_order: bool,
211 pub validate_on_migrate: bool,
213 pub clean_enabled: bool,
215 pub baseline_version: String,
217 pub installed_by: Option<String>,
219 pub environment: Option<String>,
221 pub dependency_ordering: bool,
223 pub show_progress: bool,
225 pub batch_transaction: bool,
227}
228
229impl Default for MigrationSettings {
230 fn default() -> Self {
231 Self {
232 locations: vec![PathBuf::from("db/migrations")],
233 table: "waypoint_schema_history".to_string(),
234 schema: "public".to_string(),
235 out_of_order: false,
236 validate_on_migrate: true,
237 clean_enabled: false,
238 baseline_version: "1".to_string(),
239 installed_by: None,
240 environment: None,
241 dependency_ordering: false,
242 show_progress: true,
243 batch_transaction: false,
244 }
245 }
246}
247
248#[derive(Debug, Clone, Default)]
250pub struct SimulationConfig {
251 pub simulate_before_migrate: bool,
253}
254
255#[derive(Deserialize, Default)]
258struct TomlConfig {
259 database: Option<TomlDatabaseConfig>,
260 migrations: Option<TomlMigrationSettings>,
261 hooks: Option<TomlHooksConfig>,
262 placeholders: Option<HashMap<String, String>>,
263 lint: Option<TomlLintConfig>,
264 snapshots: Option<TomlSnapshotConfig>,
265 preflight: Option<TomlPreflightConfig>,
266 databases: Option<Vec<TomlNamedDatabaseConfig>>,
267 guards: Option<TomlGuardsConfig>,
268 reversals: Option<TomlReversalConfig>,
269 safety: Option<TomlSafetyConfig>,
270 advisor: Option<TomlAdvisorConfig>,
271 simulation: Option<TomlSimulationConfig>,
272}
273
274#[derive(Deserialize, Default)]
275struct TomlDatabaseConfig {
276 url: Option<String>,
277 host: Option<String>,
278 port: Option<u16>,
279 user: Option<String>,
280 password: Option<String>,
281 database: Option<String>,
282 connect_retries: Option<u32>,
283 ssl_mode: Option<String>,
284 connect_timeout: Option<u32>,
285 statement_timeout: Option<u32>,
286 keepalive: Option<u32>,
287}
288
289#[derive(Deserialize, Default)]
290struct TomlMigrationSettings {
291 locations: Option<Vec<String>>,
292 table: Option<String>,
293 schema: Option<String>,
294 out_of_order: Option<bool>,
295 validate_on_migrate: Option<bool>,
296 clean_enabled: Option<bool>,
297 baseline_version: Option<String>,
298 installed_by: Option<String>,
299 environment: Option<String>,
300 dependency_ordering: Option<bool>,
301 show_progress: Option<bool>,
302 batch_transaction: Option<bool>,
303}
304
305#[derive(Deserialize, Default)]
306struct TomlLintConfig {
307 disabled_rules: Option<Vec<String>>,
308}
309
310#[derive(Deserialize, Default)]
311struct TomlSnapshotConfig {
312 directory: Option<String>,
313 auto_snapshot_on_migrate: Option<bool>,
314 max_snapshots: Option<usize>,
315 strip_definer_mysql: Option<bool>,
316}
317
318#[derive(Deserialize, Default)]
319struct TomlPreflightConfig {
320 enabled: Option<bool>,
321 max_replication_lag_mb: Option<i64>,
322 max_replication_lag_secs: Option<i64>,
323 long_query_threshold_secs: Option<i64>,
324}
325
326#[derive(Deserialize, Default)]
327struct TomlNamedDatabaseConfig {
328 name: Option<String>,
329 url: Option<String>,
330 depends_on: Option<Vec<String>>,
331 migrations: Option<TomlMigrationSettings>,
332 hooks: Option<TomlHooksConfig>,
333 placeholders: Option<HashMap<String, String>>,
334}
335
336#[derive(Deserialize, Default)]
337struct TomlHooksConfig {
338 before_migrate: Option<Vec<String>>,
339 after_migrate: Option<Vec<String>>,
340 before_each_migrate: Option<Vec<String>>,
341 after_each_migrate: Option<Vec<String>>,
342}
343
344#[derive(Deserialize, Default)]
345struct TomlGuardsConfig {
346 on_require_fail: Option<String>,
347}
348
349#[derive(Deserialize, Default)]
350struct TomlReversalConfig {
351 enabled: Option<bool>,
352 warn_data_loss: Option<bool>,
353}
354
355#[derive(Deserialize, Default)]
356struct TomlSafetyConfig {
357 enabled: Option<bool>,
358 block_on_danger: Option<bool>,
359 large_table_threshold: Option<i64>,
360 huge_table_threshold: Option<i64>,
361 refresh_stats_mysql: Option<bool>,
362}
363
364#[derive(Deserialize, Default)]
365struct TomlAdvisorConfig {
366 run_after_migrate: Option<bool>,
367 disabled_rules: Option<Vec<String>>,
368}
369
370#[derive(Deserialize, Default)]
371struct TomlSimulationConfig {
372 simulate_before_migrate: Option<bool>,
373}
374
375#[derive(Debug, Default, Clone)]
377pub struct CliOverrides {
378 pub url: Option<String>,
380 pub schema: Option<String>,
382 pub table: Option<String>,
384 pub locations: Option<Vec<PathBuf>>,
386 pub out_of_order: Option<bool>,
388 pub validate_on_migrate: Option<bool>,
390 pub baseline_version: Option<String>,
392 pub connect_retries: Option<u32>,
394 pub ssl_mode: Option<String>,
396 pub connect_timeout: Option<u32>,
398 pub statement_timeout: Option<u32>,
400 pub environment: Option<String>,
402 pub dependency_ordering: Option<bool>,
404 pub keepalive: Option<u32>,
406 pub batch_transaction: Option<bool>,
408}
409
410impl WaypointConfig {
411 pub fn load(config_path: Option<&str>, overrides: &CliOverrides) -> Result<Self> {
417 let mut config = WaypointConfig::default();
418
419 let toml_path = config_path.unwrap_or("waypoint.toml");
421 if let Ok(content) = std::fs::read_to_string(toml_path) {
422 #[cfg(unix)]
424 {
425 use std::os::unix::fs::PermissionsExt;
426 if let Ok(meta) = std::fs::metadata(toml_path) {
427 let mode = meta.permissions().mode();
428 if mode & 0o077 != 0 {
429 log::warn!("Config file has overly permissive permissions. Consider chmod 600.; path={}, mode={:o}", toml_path, mode);
430 }
431 }
432 }
433 let toml_config: TomlConfig = toml::from_str(&content).map_err(|e| {
434 WaypointError::ConfigError(format!(
435 "Failed to parse config file '{}': {}",
436 toml_path, e
437 ))
438 })?;
439 config.apply_toml(toml_config);
440 } else if config_path.is_some() {
441 return Err(WaypointError::ConfigError(format!(
443 "Config file '{}' not found",
444 toml_path
445 )));
446 }
447
448 config.apply_env();
450
451 config.apply_cli(overrides);
453
454 crate::db::validate_identifier(&config.migrations.schema)?;
456 crate::db::validate_identifier(&config.migrations.table)?;
457
458 if config.database.connect_retries > 20 {
460 config.database.connect_retries = 20;
461 log::warn!("connect_retries capped at 20");
462 }
463
464 Ok(config)
465 }
466
467 fn apply_toml(&mut self, toml: TomlConfig) {
468 if let Some(db) = toml.database {
469 apply_option_some!(db.url => self.database.url);
470 apply_option_some!(db.host => self.database.host);
471 apply_option_some!(db.port => self.database.port);
472 apply_option_some!(db.user => self.database.user);
473 apply_option_some!(db.password => self.database.password);
474 apply_option_some!(db.database => self.database.database);
475 apply_option!(db.connect_retries => self.database.connect_retries);
476 if let Some(v) = db.ssl_mode {
477 match v.parse() {
478 Ok(mode) => self.database.ssl_mode = mode,
479 Err(_) => log::warn!(
480 "Invalid ssl_mode '{}' in config, using default 'prefer'. Valid values: disable, prefer, require",
481 v
482 ),
483 }
484 }
485 apply_option!(db.connect_timeout => self.database.connect_timeout_secs);
486 apply_option!(db.statement_timeout => self.database.statement_timeout_secs);
487 apply_option!(db.keepalive => self.database.keepalive_secs);
488 }
489
490 if let Some(m) = toml.migrations {
491 if let Some(v) = m.locations {
492 self.migrations.locations = v.into_iter().map(|s| normalize_location(&s)).collect();
493 }
494 apply_option!(m.table => self.migrations.table);
495 apply_option!(m.schema => self.migrations.schema);
496 apply_option!(m.out_of_order => self.migrations.out_of_order);
497 apply_option!(m.validate_on_migrate => self.migrations.validate_on_migrate);
498 apply_option!(m.clean_enabled => self.migrations.clean_enabled);
499 apply_option!(m.baseline_version => self.migrations.baseline_version);
500 apply_option_some!(m.installed_by => self.migrations.installed_by);
501 apply_option_some!(m.environment => self.migrations.environment);
502 apply_option!(m.dependency_ordering => self.migrations.dependency_ordering);
503 apply_option!(m.show_progress => self.migrations.show_progress);
504 apply_option!(m.batch_transaction => self.migrations.batch_transaction);
505 }
506
507 if let Some(h) = toml.hooks {
508 if let Some(v) = h.before_migrate {
509 self.hooks.before_migrate = v.into_iter().map(PathBuf::from).collect();
510 }
511 if let Some(v) = h.after_migrate {
512 self.hooks.after_migrate = v.into_iter().map(PathBuf::from).collect();
513 }
514 if let Some(v) = h.before_each_migrate {
515 self.hooks.before_each_migrate = v.into_iter().map(PathBuf::from).collect();
516 }
517 if let Some(v) = h.after_each_migrate {
518 self.hooks.after_each_migrate = v.into_iter().map(PathBuf::from).collect();
519 }
520 }
521
522 if let Some(p) = toml.placeholders {
523 self.placeholders.extend(p);
524 }
525
526 if let Some(l) = toml.lint {
527 apply_option!(l.disabled_rules => self.lint.disabled_rules);
528 }
529
530 if let Some(s) = toml.snapshots {
531 if let Some(v) = s.directory {
532 self.snapshots.directory = PathBuf::from(v);
533 }
534 apply_option!(s.auto_snapshot_on_migrate => self.snapshots.auto_snapshot_on_migrate);
535 apply_option!(s.max_snapshots => self.snapshots.max_snapshots);
536 apply_option!(s.strip_definer_mysql => self.snapshots.strip_definer_mysql);
537 }
538
539 if let Some(p) = toml.preflight {
540 apply_option!(p.enabled => self.preflight.enabled);
541 apply_option!(p.max_replication_lag_mb => self.preflight.max_replication_lag_mb);
542 apply_option!(p.max_replication_lag_secs => self.preflight.max_replication_lag_secs);
543 apply_option!(p.long_query_threshold_secs => self.preflight.long_query_threshold_secs);
544 }
545
546 if let Some(g) = toml.guards {
547 if let Some(v) = g.on_require_fail {
548 match v.parse() {
549 Ok(policy) => self.guards.on_require_fail = policy,
550 Err(_) => log::warn!(
551 "Invalid on_require_fail '{}' in config, using default 'error'. Valid values: error, warn, skip",
552 v
553 ),
554 }
555 }
556 }
557
558 if let Some(r) = toml.reversals {
559 apply_option!(r.enabled => self.reversals.enabled);
560 apply_option!(r.warn_data_loss => self.reversals.warn_data_loss);
561 }
562
563 if let Some(s) = toml.safety {
564 apply_option!(s.enabled => self.safety.enabled);
565 apply_option!(s.block_on_danger => self.safety.block_on_danger);
566 apply_option!(s.large_table_threshold => self.safety.large_table_threshold);
567 apply_option!(s.huge_table_threshold => self.safety.huge_table_threshold);
568 apply_option!(s.refresh_stats_mysql => self.safety.refresh_stats_mysql);
569 }
570
571 if let Some(a) = toml.advisor {
572 apply_option!(a.run_after_migrate => self.advisor.run_after_migrate);
573 apply_option!(a.disabled_rules => self.advisor.disabled_rules);
574 }
575
576 if let Some(s) = toml.simulation {
577 apply_option!(s.simulate_before_migrate => self.simulation.simulate_before_migrate);
578 }
579
580 if let Some(databases) = toml.databases {
581 let mut named_dbs = Vec::new();
582 for db in databases {
583 let name = db.name.unwrap_or_default();
584 let mut db_config = DatabaseConfig::default();
585 apply_option_some!(db.url => db_config.url);
586 let env_url_key = format!("WAYPOINT_DB_{}_URL", name.to_uppercase());
588 if let Ok(url) = std::env::var(&env_url_key) {
589 db_config.url = Some(url);
590 }
591
592 let mut mig_settings = MigrationSettings::default();
593 if let Some(m) = db.migrations {
594 if let Some(v) = m.locations {
595 mig_settings.locations =
596 v.into_iter().map(|s| normalize_location(&s)).collect();
597 }
598 apply_option!(m.table => mig_settings.table);
599 apply_option!(m.schema => mig_settings.schema);
600 apply_option!(m.out_of_order => mig_settings.out_of_order);
601 apply_option!(m.validate_on_migrate => mig_settings.validate_on_migrate);
602 apply_option!(m.clean_enabled => mig_settings.clean_enabled);
603 apply_option!(m.baseline_version => mig_settings.baseline_version);
604 apply_option_some!(m.installed_by => mig_settings.installed_by);
605 apply_option_some!(m.environment => mig_settings.environment);
606 apply_option!(m.dependency_ordering => mig_settings.dependency_ordering);
607 apply_option!(m.show_progress => mig_settings.show_progress);
608 apply_option!(m.batch_transaction => mig_settings.batch_transaction);
609 }
610
611 let mut hooks_config = HooksConfig::default();
612 if let Some(h) = db.hooks {
613 if let Some(v) = h.before_migrate {
614 hooks_config.before_migrate = v.into_iter().map(PathBuf::from).collect();
615 }
616 if let Some(v) = h.after_migrate {
617 hooks_config.after_migrate = v.into_iter().map(PathBuf::from).collect();
618 }
619 if let Some(v) = h.before_each_migrate {
620 hooks_config.before_each_migrate =
621 v.into_iter().map(PathBuf::from).collect();
622 }
623 if let Some(v) = h.after_each_migrate {
624 hooks_config.after_each_migrate =
625 v.into_iter().map(PathBuf::from).collect();
626 }
627 }
628
629 named_dbs.push(crate::multi::NamedDatabaseConfig {
630 name,
631 database: db_config,
632 migrations: mig_settings,
633 hooks: hooks_config,
634 placeholders: db.placeholders.unwrap_or_default(),
635 depends_on: db.depends_on.unwrap_or_default(),
636 });
637 }
638 self.multi_database = Some(named_dbs);
639 }
640 }
641
642 fn apply_env(&mut self) {
643 if let Ok(v) = std::env::var("WAYPOINT_DATABASE_URL") {
644 self.database.url = Some(v);
645 }
646 if let Ok(v) = std::env::var("WAYPOINT_DATABASE_HOST") {
647 self.database.host = Some(v);
648 }
649 if let Ok(v) = std::env::var("WAYPOINT_DATABASE_PORT") {
650 if let Ok(port) = v.parse::<u16>() {
651 self.database.port = Some(port);
652 }
653 }
654 if let Ok(v) = std::env::var("WAYPOINT_DATABASE_USER") {
655 self.database.user = Some(v);
656 }
657 if let Ok(v) = std::env::var("WAYPOINT_DATABASE_PASSWORD") {
658 self.database.password = Some(v);
659 }
660 if let Ok(v) = std::env::var("WAYPOINT_DATABASE_NAME") {
661 self.database.database = Some(v);
662 }
663 if let Ok(v) = std::env::var("WAYPOINT_CONNECT_RETRIES") {
664 if let Ok(n) = v.parse::<u32>() {
665 self.database.connect_retries = n;
666 }
667 }
668 if let Ok(v) = std::env::var("WAYPOINT_SSL_MODE") {
669 if let Ok(mode) = v.parse() {
670 self.database.ssl_mode = mode;
671 }
672 }
673 if let Ok(v) = std::env::var("WAYPOINT_CONNECT_TIMEOUT") {
674 if let Ok(n) = v.parse::<u32>() {
675 self.database.connect_timeout_secs = n;
676 }
677 }
678 if let Ok(v) = std::env::var("WAYPOINT_STATEMENT_TIMEOUT") {
679 if let Ok(n) = v.parse::<u32>() {
680 self.database.statement_timeout_secs = n;
681 }
682 }
683 if let Ok(v) = std::env::var("WAYPOINT_MIGRATIONS_LOCATIONS") {
684 self.migrations.locations =
685 v.split(',').map(|s| normalize_location(s.trim())).collect();
686 }
687 if let Ok(v) = std::env::var("WAYPOINT_MIGRATIONS_TABLE") {
688 self.migrations.table = v;
689 }
690 if let Ok(v) = std::env::var("WAYPOINT_MIGRATIONS_SCHEMA") {
691 self.migrations.schema = v;
692 }
693
694 if let Ok(v) = std::env::var("WAYPOINT_KEEPALIVE") {
695 if let Ok(n) = v.parse::<u32>() {
696 self.database.keepalive_secs = n;
697 }
698 }
699 if let Ok(v) = std::env::var("WAYPOINT_BATCH_TRANSACTION") {
700 self.migrations.batch_transaction = v == "1" || v.eq_ignore_ascii_case("true");
701 }
702 if let Ok(v) = std::env::var("WAYPOINT_ENVIRONMENT") {
703 self.migrations.environment = Some(v);
704 }
705
706 for (key, value) in std::env::vars() {
708 if let Some(placeholder_key) = key.strip_prefix("WAYPOINT_PLACEHOLDER_") {
709 self.placeholders
710 .insert(placeholder_key.to_lowercase(), value);
711 }
712 }
713 }
714
715 fn apply_cli(&mut self, overrides: &CliOverrides) {
716 apply_option_some_clone!(overrides.url => self.database.url);
717 apply_option_clone!(overrides.schema => self.migrations.schema);
718 apply_option_clone!(overrides.table => self.migrations.table);
719 apply_option_clone!(overrides.locations => self.migrations.locations);
720 apply_option!(overrides.out_of_order => self.migrations.out_of_order);
721 apply_option!(overrides.validate_on_migrate => self.migrations.validate_on_migrate);
722 apply_option_clone!(overrides.baseline_version => self.migrations.baseline_version);
723 apply_option!(overrides.connect_retries => self.database.connect_retries);
724 if let Some(ref v) = overrides.ssl_mode {
725 if let Ok(mode) = v.parse() {
727 self.database.ssl_mode = mode;
728 }
729 }
730 apply_option!(overrides.connect_timeout => self.database.connect_timeout_secs);
731 apply_option!(overrides.statement_timeout => self.database.statement_timeout_secs);
732 apply_option_some_clone!(overrides.environment => self.migrations.environment);
733 apply_option!(overrides.dependency_ordering => self.migrations.dependency_ordering);
734 apply_option!(overrides.keepalive => self.database.keepalive_secs);
735 apply_option!(overrides.batch_transaction => self.migrations.batch_transaction);
736 }
737
738 pub fn connection_string(&self) -> Result<String> {
743 if let Some(ref url) = self.database.url {
744 return Ok(normalize_jdbc_url(url));
745 }
746
747 let host = self.database.host.as_deref().unwrap_or("localhost");
748 let port = self.database.port.unwrap_or(5432);
749 let user =
750 self.database.user.as_deref().ok_or_else(|| {
751 WaypointError::ConfigError("Database user is required".to_string())
752 })?;
753 let database =
754 self.database.database.as_deref().ok_or_else(|| {
755 WaypointError::ConfigError("Database name is required".to_string())
756 })?;
757
758 let mut url = format!(
759 "host={} port={} user={} dbname={}",
760 host, port, user, database
761 );
762
763 if let Some(ref password) = self.database.password {
764 let escaped = password.replace('\\', "\\\\").replace('\'', "\\'");
766 url.push_str(&format!(" password='{}'", escaped));
767 }
768
769 Ok(url)
770 }
771}
772
773fn normalize_jdbc_url(url: &str) -> String {
780 let url = url.strip_prefix("jdbc:").unwrap_or(url);
782
783 if let Some((base, query)) = url.split_once('?') {
785 let mut user = None;
786 let mut password = None;
787 let mut other_params = Vec::new();
788
789 for param in query.split('&') {
790 if let Some((key, value)) = param.split_once('=') {
791 match key.to_lowercase().as_str() {
792 "user" => user = Some(value.to_string()),
793 "password" => password = Some(value.to_string()),
794 _ => other_params.push(param.to_string()),
795 }
796 }
797 }
798
799 if user.is_some() || password.is_some() {
801 if let Some(rest) = base
802 .strip_prefix("postgresql://")
803 .or_else(|| base.strip_prefix("postgres://"))
804 {
805 let scheme = if base.starts_with("postgresql://") {
806 "postgresql"
807 } else {
808 "postgres"
809 };
810
811 let auth = match (user, password) {
812 (Some(u), Some(p)) => format!("{}:{}@", u, p),
813 (Some(u), None) => format!("{}@", u),
814 (None, Some(p)) => format!(":{p}@"),
815 (None, None) => String::new(),
816 };
817
818 let mut result = format!("{}://{}{}", scheme, auth, rest);
819 if !other_params.is_empty() {
820 result.push('?');
821 result.push_str(&other_params.join("&"));
822 }
823 return result;
824 }
825 }
826
827 if other_params.is_empty() {
829 return base.to_string();
830 }
831 return format!("{}?{}", base, other_params.join("&"));
832 }
833
834 url.to_string()
835}
836
837pub fn normalize_location(location: &str) -> PathBuf {
839 let stripped = location.strip_prefix("filesystem:").unwrap_or(location);
840 PathBuf::from(stripped)
841}
842
843#[cfg(test)]
844mod tests {
845 use super::*;
846
847 #[test]
848 fn test_default_config() {
849 let config = WaypointConfig::default();
850 assert_eq!(config.migrations.table, "waypoint_schema_history");
851 assert_eq!(config.migrations.schema, "public");
852 assert!(!config.migrations.out_of_order);
853 assert!(config.migrations.validate_on_migrate);
854 assert!(!config.migrations.clean_enabled);
855 assert_eq!(config.migrations.baseline_version, "1");
856 assert_eq!(
857 config.migrations.locations,
858 vec![PathBuf::from("db/migrations")]
859 );
860 }
861
862 #[test]
863 fn test_connection_string_from_url() {
864 let mut config = WaypointConfig::default();
865 config.database.url = Some("postgres://user:pass@localhost/db".to_string());
866 assert_eq!(
867 config.connection_string().unwrap(),
868 "postgres://user:pass@localhost/db"
869 );
870 }
871
872 #[test]
873 fn test_connection_string_from_fields() {
874 let mut config = WaypointConfig::default();
875 config.database.host = Some("myhost".to_string());
876 config.database.port = Some(5433);
877 config.database.user = Some("myuser".to_string());
878 config.database.database = Some("mydb".to_string());
879 config.database.password = Some("secret".to_string());
880
881 let conn = config.connection_string().unwrap();
882 assert!(conn.contains("host=myhost"));
883 assert!(conn.contains("port=5433"));
884 assert!(conn.contains("user=myuser"));
885 assert!(conn.contains("dbname=mydb"));
886 assert!(conn.contains("password='secret'"));
887 }
888
889 #[test]
890 fn test_connection_string_missing_user() {
891 let mut config = WaypointConfig::default();
892 config.database.database = Some("mydb".to_string());
893 assert!(config.connection_string().is_err());
894 }
895
896 #[test]
897 fn test_cli_overrides() {
898 let mut config = WaypointConfig::default();
899 let overrides = CliOverrides {
900 url: Some("postgres://override@localhost/db".to_string()),
901 schema: Some("custom_schema".to_string()),
902 table: Some("custom_table".to_string()),
903 locations: Some(vec![PathBuf::from("custom/path")]),
904 out_of_order: Some(true),
905 validate_on_migrate: Some(false),
906 baseline_version: Some("5".to_string()),
907 connect_retries: None,
908 ssl_mode: None,
909 connect_timeout: None,
910 statement_timeout: None,
911 environment: None,
912 dependency_ordering: None,
913 keepalive: None,
914 batch_transaction: None,
915 };
916
917 config.apply_cli(&overrides);
918
919 assert_eq!(
920 config.database.url.as_deref(),
921 Some("postgres://override@localhost/db")
922 );
923 assert_eq!(config.migrations.schema, "custom_schema");
924 assert_eq!(config.migrations.table, "custom_table");
925 assert_eq!(
926 config.migrations.locations,
927 vec![PathBuf::from("custom/path")]
928 );
929 assert!(config.migrations.out_of_order);
930 assert!(!config.migrations.validate_on_migrate);
931 assert_eq!(config.migrations.baseline_version, "5");
932 }
933
934 #[test]
935 fn test_toml_parsing() {
936 let toml_str = r#"
937[database]
938url = "postgres://user:pass@localhost/mydb"
939
940[migrations]
941table = "my_history"
942schema = "app"
943out_of_order = true
944locations = ["sql/migrations", "sql/seeds"]
945
946[placeholders]
947env = "production"
948app_name = "myapp"
949"#;
950
951 let toml_config: TomlConfig = toml::from_str(toml_str).unwrap();
952 let mut config = WaypointConfig::default();
953 config.apply_toml(toml_config);
954
955 assert_eq!(
956 config.database.url.as_deref(),
957 Some("postgres://user:pass@localhost/mydb")
958 );
959 assert_eq!(config.migrations.table, "my_history");
960 assert_eq!(config.migrations.schema, "app");
961 assert!(config.migrations.out_of_order);
962 assert_eq!(
963 config.migrations.locations,
964 vec![PathBuf::from("sql/migrations"), PathBuf::from("sql/seeds")]
965 );
966 assert_eq!(config.placeholders.get("env").unwrap(), "production");
967 assert_eq!(config.placeholders.get("app_name").unwrap(), "myapp");
968 }
969
970 #[test]
971 fn test_normalize_jdbc_url_with_credentials() {
972 let url = "jdbc:postgresql://myhost:5432/mydb?user=admin&password=secret";
973 assert_eq!(
974 normalize_jdbc_url(url),
975 "postgresql://admin:secret@myhost:5432/mydb"
976 );
977 }
978
979 #[test]
980 fn test_normalize_jdbc_url_user_only() {
981 let url = "jdbc:postgresql://myhost:5432/mydb?user=admin";
982 assert_eq!(
983 normalize_jdbc_url(url),
984 "postgresql://admin@myhost:5432/mydb"
985 );
986 }
987
988 #[test]
989 fn test_normalize_jdbc_url_strips_jdbc_prefix() {
990 let url = "jdbc:postgresql://myhost:5432/mydb";
991 assert_eq!(normalize_jdbc_url(url), "postgresql://myhost:5432/mydb");
992 }
993
994 #[test]
995 fn test_normalize_jdbc_url_passthrough() {
996 let url = "postgresql://user:pass@myhost:5432/mydb";
997 assert_eq!(normalize_jdbc_url(url), url);
998 }
999
1000 #[test]
1001 fn test_normalize_jdbc_url_preserves_other_params() {
1002 let url = "jdbc:postgresql://myhost:5432/mydb?user=admin&password=secret&sslmode=require";
1003 assert_eq!(
1004 normalize_jdbc_url(url),
1005 "postgresql://admin:secret@myhost:5432/mydb?sslmode=require"
1006 );
1007 }
1008
1009 #[test]
1010 fn test_normalize_location_filesystem_prefix() {
1011 assert_eq!(
1012 normalize_location("filesystem:/flyway/sql"),
1013 PathBuf::from("/flyway/sql")
1014 );
1015 }
1016
1017 #[test]
1018 fn test_normalize_location_plain_path() {
1019 assert_eq!(
1020 normalize_location("/my/migrations"),
1021 PathBuf::from("/my/migrations")
1022 );
1023 }
1024
1025 #[test]
1026 fn test_normalize_location_relative() {
1027 assert_eq!(
1028 normalize_location("filesystem:db/migrations"),
1029 PathBuf::from("db/migrations")
1030 );
1031 }
1032
1033 #[test]
1034 fn test_connection_string_password_special_chars() {
1035 let config = WaypointConfig {
1036 database: DatabaseConfig {
1037 host: Some("localhost".to_string()),
1038 port: Some(5432),
1039 user: Some("admin".to_string()),
1040 database: Some("mydb".to_string()),
1041 password: Some("p@ss'w ord".to_string()),
1042 ..Default::default()
1043 },
1044 ..Default::default()
1045 };
1046 let conn = config.connection_string().unwrap();
1047 assert!(conn.contains("password='p@ss\\'w ord'"));
1048 }
1049}