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 pub engine: crate::dialect::DialectKind,
148}
149
150impl Default for DatabaseConfig {
151 fn default() -> Self {
152 Self {
153 url: None,
154 host: None,
155 port: None,
156 user: None,
157 password: None,
158 database: None,
159 connect_retries: 0,
160 ssl_mode: SslMode::Prefer,
161 connect_timeout_secs: 30,
162 statement_timeout_secs: 0,
163 keepalive_secs: 120,
164 engine: crate::dialect::DialectKind::Postgres,
165 }
166 }
167}
168
169impl fmt::Debug for DatabaseConfig {
170 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171 f.debug_struct("DatabaseConfig")
172 .field("url", &self.url.as_ref().map(|_| "[REDACTED]"))
173 .field("host", &self.host)
174 .field("port", &self.port)
175 .field("user", &self.user)
176 .field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
177 .field("database", &self.database)
178 .field("connect_retries", &self.connect_retries)
179 .field("ssl_mode", &self.ssl_mode)
180 .field("connect_timeout_secs", &self.connect_timeout_secs)
181 .field("statement_timeout_secs", &self.statement_timeout_secs)
182 .field("keepalive_secs", &self.keepalive_secs)
183 .field("engine", &self.engine)
184 .finish()
185 }
186}
187
188#[derive(Debug, Clone, Default)]
190pub struct HooksConfig {
191 pub before_migrate: Vec<PathBuf>,
193 pub after_migrate: Vec<PathBuf>,
195 pub before_each_migrate: Vec<PathBuf>,
197 pub after_each_migrate: Vec<PathBuf>,
199}
200
201#[derive(Debug, Clone, Default)]
203pub struct LintConfig {
204 pub disabled_rules: Vec<String>,
206}
207
208#[derive(Debug, Clone)]
210pub struct MigrationSettings {
211 pub locations: Vec<PathBuf>,
213 pub table: String,
215 pub schema: String,
217 pub out_of_order: bool,
219 pub validate_on_migrate: bool,
221 pub clean_enabled: bool,
223 pub baseline_version: String,
225 pub installed_by: Option<String>,
227 pub environment: Option<String>,
229 pub dependency_ordering: bool,
231 pub show_progress: bool,
233 pub batch_transaction: bool,
235}
236
237impl Default for MigrationSettings {
238 fn default() -> Self {
239 Self {
240 locations: vec![PathBuf::from("db/migrations")],
241 table: "waypoint_schema_history".to_string(),
242 schema: "public".to_string(),
243 out_of_order: false,
244 validate_on_migrate: true,
245 clean_enabled: false,
246 baseline_version: "1".to_string(),
247 installed_by: None,
248 environment: None,
249 dependency_ordering: false,
250 show_progress: true,
251 batch_transaction: false,
252 }
253 }
254}
255
256#[derive(Debug, Clone, Default)]
258pub struct SimulationConfig {
259 pub simulate_before_migrate: bool,
261}
262
263#[derive(Deserialize, Default)]
266struct TomlConfig {
267 database: Option<TomlDatabaseConfig>,
268 migrations: Option<TomlMigrationSettings>,
269 hooks: Option<TomlHooksConfig>,
270 placeholders: Option<HashMap<String, String>>,
271 lint: Option<TomlLintConfig>,
272 snapshots: Option<TomlSnapshotConfig>,
273 preflight: Option<TomlPreflightConfig>,
274 databases: Option<Vec<TomlNamedDatabaseConfig>>,
275 guards: Option<TomlGuardsConfig>,
276 reversals: Option<TomlReversalConfig>,
277 safety: Option<TomlSafetyConfig>,
278 advisor: Option<TomlAdvisorConfig>,
279 simulation: Option<TomlSimulationConfig>,
280}
281
282#[derive(Deserialize, Default)]
283struct TomlDatabaseConfig {
284 url: Option<String>,
285 host: Option<String>,
286 port: Option<u16>,
287 user: Option<String>,
288 password: Option<String>,
289 database: Option<String>,
290 connect_retries: Option<u32>,
291 ssl_mode: Option<String>,
292 connect_timeout: Option<u32>,
293 statement_timeout: Option<u32>,
294 keepalive: Option<u32>,
295 engine: Option<String>,
296}
297
298#[derive(Deserialize, Default)]
299struct TomlMigrationSettings {
300 locations: Option<Vec<String>>,
301 table: Option<String>,
302 schema: Option<String>,
303 out_of_order: Option<bool>,
304 validate_on_migrate: Option<bool>,
305 clean_enabled: Option<bool>,
306 baseline_version: Option<String>,
307 installed_by: Option<String>,
308 environment: Option<String>,
309 dependency_ordering: Option<bool>,
310 show_progress: Option<bool>,
311 batch_transaction: Option<bool>,
312}
313
314#[derive(Deserialize, Default)]
315struct TomlLintConfig {
316 disabled_rules: Option<Vec<String>>,
317}
318
319#[derive(Deserialize, Default)]
320struct TomlSnapshotConfig {
321 directory: Option<String>,
322 auto_snapshot_on_migrate: Option<bool>,
323 max_snapshots: Option<usize>,
324 strip_definer_mysql: Option<bool>,
325}
326
327#[derive(Deserialize, Default)]
328struct TomlPreflightConfig {
329 enabled: Option<bool>,
330 max_replication_lag_mb: Option<i64>,
331 max_replication_lag_secs: Option<i64>,
332 long_query_threshold_secs: Option<i64>,
333}
334
335#[derive(Deserialize, Default)]
336struct TomlNamedDatabaseConfig {
337 name: Option<String>,
338 url: Option<String>,
339 depends_on: Option<Vec<String>>,
340 migrations: Option<TomlMigrationSettings>,
341 hooks: Option<TomlHooksConfig>,
342 placeholders: Option<HashMap<String, String>>,
343}
344
345#[derive(Deserialize, Default)]
346struct TomlHooksConfig {
347 before_migrate: Option<Vec<String>>,
348 after_migrate: Option<Vec<String>>,
349 before_each_migrate: Option<Vec<String>>,
350 after_each_migrate: Option<Vec<String>>,
351}
352
353#[derive(Deserialize, Default)]
354struct TomlGuardsConfig {
355 enabled: Option<bool>,
356 on_require_fail: Option<String>,
357}
358
359#[derive(Deserialize, Default)]
360struct TomlReversalConfig {
361 enabled: Option<bool>,
362 warn_data_loss: Option<bool>,
363}
364
365#[derive(Deserialize, Default)]
366struct TomlSafetyConfig {
367 enabled: Option<bool>,
368 block_on_danger: Option<bool>,
369 large_table_threshold: Option<i64>,
370 huge_table_threshold: Option<i64>,
371 refresh_stats_mysql: Option<bool>,
372}
373
374#[derive(Deserialize, Default)]
375struct TomlAdvisorConfig {
376 run_after_migrate: Option<bool>,
377 disabled_rules: Option<Vec<String>>,
378}
379
380#[derive(Deserialize, Default)]
381struct TomlSimulationConfig {
382 simulate_before_migrate: Option<bool>,
383}
384
385#[derive(Debug, Default, Clone)]
387pub struct CliOverrides {
388 pub url: Option<String>,
390 pub schema: Option<String>,
392 pub table: Option<String>,
394 pub locations: Option<Vec<PathBuf>>,
396 pub out_of_order: Option<bool>,
398 pub validate_on_migrate: Option<bool>,
400 pub baseline_version: Option<String>,
402 pub connect_retries: Option<u32>,
404 pub ssl_mode: Option<String>,
406 pub connect_timeout: Option<u32>,
408 pub statement_timeout: Option<u32>,
410 pub environment: Option<String>,
412 pub dependency_ordering: Option<bool>,
414 pub keepalive: Option<u32>,
416 pub batch_transaction: Option<bool>,
418}
419
420impl WaypointConfig {
421 pub fn load(config_path: Option<&str>, overrides: &CliOverrides) -> Result<Self> {
427 let mut config = WaypointConfig::default();
428
429 let toml_path = config_path.unwrap_or("waypoint.toml");
431 if let Ok(content) = std::fs::read_to_string(toml_path) {
432 #[cfg(unix)]
434 {
435 use std::os::unix::fs::PermissionsExt;
436 if let Ok(meta) = std::fs::metadata(toml_path) {
437 let mode = meta.permissions().mode();
438 if mode & 0o077 != 0 {
439 log::warn!(
440 "Config file has overly permissive permissions. Consider chmod 600.; path={}, mode={:o}",
441 toml_path,
442 mode
443 );
444 }
445 }
446 }
447 let toml_config: TomlConfig = toml::from_str(&content).map_err(|e| {
448 WaypointError::ConfigError(format!(
449 "Failed to parse config file '{}': {}",
450 toml_path, e
451 ))
452 })?;
453 config.apply_toml(toml_config);
454 } else if config_path.is_some() {
455 return Err(WaypointError::ConfigError(format!(
457 "Config file '{}' not found",
458 toml_path
459 )));
460 }
461
462 config.apply_env();
464
465 config.apply_cli(overrides);
467
468 crate::db::validate_identifier(&config.migrations.schema)?;
470 crate::db::validate_identifier(&config.migrations.table)?;
471
472 if config.database.connect_retries > 20 {
474 config.database.connect_retries = 20;
475 log::warn!("connect_retries capped at 20");
476 }
477
478 Ok(config)
479 }
480
481 fn apply_toml(&mut self, toml: TomlConfig) {
482 if let Some(db) = toml.database {
483 apply_option_some!(db.url => self.database.url);
484 apply_option_some!(db.host => self.database.host);
485 apply_option_some!(db.port => self.database.port);
486 apply_option_some!(db.user => self.database.user);
487 apply_option_some!(db.password => self.database.password);
488 apply_option_some!(db.database => self.database.database);
489 apply_option!(db.connect_retries => self.database.connect_retries);
490 if let Some(v) = db.ssl_mode {
491 match v.parse() {
492 Ok(mode) => self.database.ssl_mode = mode,
493 Err(_) => log::warn!(
494 "Invalid ssl_mode '{}' in config, using default 'prefer'. Valid values: disable, prefer, require",
495 v
496 ),
497 }
498 }
499 apply_option!(db.connect_timeout => self.database.connect_timeout_secs);
500 apply_option!(db.statement_timeout => self.database.statement_timeout_secs);
501 apply_option!(db.keepalive => self.database.keepalive_secs);
502 if let Some(v) = db.engine {
503 match v.parse() {
504 Ok(kind) => self.database.engine = kind,
505 Err(_) => log::warn!(
506 "Invalid engine '{}' in config, using default 'postgres'. Valid values: postgres, mysql",
507 v
508 ),
509 }
510 }
511 }
512
513 if let Some(m) = toml.migrations {
514 if let Some(v) = m.locations {
515 self.migrations.locations = v.into_iter().map(|s| normalize_location(&s)).collect();
516 }
517 apply_option!(m.table => self.migrations.table);
518 apply_option!(m.schema => self.migrations.schema);
519 apply_option!(m.out_of_order => self.migrations.out_of_order);
520 apply_option!(m.validate_on_migrate => self.migrations.validate_on_migrate);
521 apply_option!(m.clean_enabled => self.migrations.clean_enabled);
522 apply_option!(m.baseline_version => self.migrations.baseline_version);
523 apply_option_some!(m.installed_by => self.migrations.installed_by);
524 apply_option_some!(m.environment => self.migrations.environment);
525 apply_option!(m.dependency_ordering => self.migrations.dependency_ordering);
526 apply_option!(m.show_progress => self.migrations.show_progress);
527 apply_option!(m.batch_transaction => self.migrations.batch_transaction);
528 }
529
530 if let Some(h) = toml.hooks {
531 if let Some(v) = h.before_migrate {
532 self.hooks.before_migrate = v.into_iter().map(PathBuf::from).collect();
533 }
534 if let Some(v) = h.after_migrate {
535 self.hooks.after_migrate = v.into_iter().map(PathBuf::from).collect();
536 }
537 if let Some(v) = h.before_each_migrate {
538 self.hooks.before_each_migrate = v.into_iter().map(PathBuf::from).collect();
539 }
540 if let Some(v) = h.after_each_migrate {
541 self.hooks.after_each_migrate = v.into_iter().map(PathBuf::from).collect();
542 }
543 }
544
545 if let Some(p) = toml.placeholders {
546 self.placeholders.extend(p);
547 }
548
549 if let Some(l) = toml.lint {
550 apply_option!(l.disabled_rules => self.lint.disabled_rules);
551 }
552
553 if let Some(s) = toml.snapshots {
554 if let Some(v) = s.directory {
555 self.snapshots.directory = PathBuf::from(v);
556 }
557 apply_option!(s.auto_snapshot_on_migrate => self.snapshots.auto_snapshot_on_migrate);
558 apply_option!(s.max_snapshots => self.snapshots.max_snapshots);
559 apply_option!(s.strip_definer_mysql => self.snapshots.strip_definer_mysql);
560 }
561
562 if let Some(p) = toml.preflight {
563 apply_option!(p.enabled => self.preflight.enabled);
564 apply_option!(p.max_replication_lag_mb => self.preflight.max_replication_lag_mb);
565 apply_option!(p.max_replication_lag_secs => self.preflight.max_replication_lag_secs);
566 apply_option!(p.long_query_threshold_secs => self.preflight.long_query_threshold_secs);
567 }
568
569 if let Some(g) = toml.guards {
570 apply_option!(g.enabled => self.guards.enabled);
571 if let Some(v) = g.on_require_fail {
572 match v.parse() {
573 Ok(policy) => self.guards.on_require_fail = policy,
574 Err(_) => log::warn!(
575 "Invalid on_require_fail '{}' in config, using default 'error'. Valid values: error, warn, skip",
576 v
577 ),
578 }
579 }
580 }
581
582 if let Some(r) = toml.reversals {
583 apply_option!(r.enabled => self.reversals.enabled);
584 apply_option!(r.warn_data_loss => self.reversals.warn_data_loss);
585 }
586
587 if let Some(s) = toml.safety {
588 apply_option!(s.enabled => self.safety.enabled);
589 apply_option!(s.block_on_danger => self.safety.block_on_danger);
590 apply_option!(s.large_table_threshold => self.safety.large_table_threshold);
591 apply_option!(s.huge_table_threshold => self.safety.huge_table_threshold);
592 apply_option!(s.refresh_stats_mysql => self.safety.refresh_stats_mysql);
593 }
594
595 if let Some(a) = toml.advisor {
596 apply_option!(a.run_after_migrate => self.advisor.run_after_migrate);
597 apply_option!(a.disabled_rules => self.advisor.disabled_rules);
598 }
599
600 if let Some(s) = toml.simulation {
601 apply_option!(s.simulate_before_migrate => self.simulation.simulate_before_migrate);
602 }
603
604 if let Some(databases) = toml.databases {
605 let mut named_dbs = Vec::new();
606 for db in databases {
607 let name = db.name.unwrap_or_default();
608 let mut db_config = DatabaseConfig {
615 url: None,
616 host: None,
617 port: None,
618 user: None,
619 password: None,
620 database: None,
621 ..self.database.clone()
622 };
623 apply_option_some!(db.url => db_config.url);
624 let env_url_key = format!("WAYPOINT_DB_{}_URL", name.to_uppercase());
626 if let Ok(url) = std::env::var(&env_url_key) {
627 db_config.url = Some(url);
628 }
629
630 let mut mig_settings = MigrationSettings::default();
631 if let Some(m) = db.migrations {
632 if let Some(v) = m.locations {
633 mig_settings.locations =
634 v.into_iter().map(|s| normalize_location(&s)).collect();
635 }
636 apply_option!(m.table => mig_settings.table);
637 apply_option!(m.schema => mig_settings.schema);
638 apply_option!(m.out_of_order => mig_settings.out_of_order);
639 apply_option!(m.validate_on_migrate => mig_settings.validate_on_migrate);
640 apply_option!(m.clean_enabled => mig_settings.clean_enabled);
641 apply_option!(m.baseline_version => mig_settings.baseline_version);
642 apply_option_some!(m.installed_by => mig_settings.installed_by);
643 apply_option_some!(m.environment => mig_settings.environment);
644 apply_option!(m.dependency_ordering => mig_settings.dependency_ordering);
645 apply_option!(m.show_progress => mig_settings.show_progress);
646 apply_option!(m.batch_transaction => mig_settings.batch_transaction);
647 }
648
649 let mut hooks_config = HooksConfig::default();
650 if let Some(h) = db.hooks {
651 if let Some(v) = h.before_migrate {
652 hooks_config.before_migrate = v.into_iter().map(PathBuf::from).collect();
653 }
654 if let Some(v) = h.after_migrate {
655 hooks_config.after_migrate = v.into_iter().map(PathBuf::from).collect();
656 }
657 if let Some(v) = h.before_each_migrate {
658 hooks_config.before_each_migrate =
659 v.into_iter().map(PathBuf::from).collect();
660 }
661 if let Some(v) = h.after_each_migrate {
662 hooks_config.after_each_migrate =
663 v.into_iter().map(PathBuf::from).collect();
664 }
665 }
666
667 named_dbs.push(crate::multi::NamedDatabaseConfig {
668 name,
669 database: db_config,
670 migrations: mig_settings,
671 hooks: hooks_config,
672 placeholders: db.placeholders.unwrap_or_default(),
673 depends_on: db.depends_on.unwrap_or_default(),
674 });
675 }
676 self.multi_database = Some(named_dbs);
677 }
678 }
679
680 fn apply_env(&mut self) {
681 if let Ok(v) = std::env::var("WAYPOINT_DATABASE_URL") {
682 self.database.url = Some(v);
683 }
684 if let Ok(v) = std::env::var("WAYPOINT_DATABASE_HOST") {
685 self.database.host = Some(v);
686 }
687 if let Ok(v) = std::env::var("WAYPOINT_DATABASE_PORT")
688 && let Ok(port) = v.parse::<u16>()
689 {
690 self.database.port = Some(port);
691 }
692 if let Ok(v) = std::env::var("WAYPOINT_DATABASE_USER") {
693 self.database.user = Some(v);
694 }
695 if let Ok(v) = std::env::var("WAYPOINT_DATABASE_PASSWORD") {
696 self.database.password = Some(v);
697 }
698 if let Ok(v) = std::env::var("WAYPOINT_DATABASE_NAME") {
699 self.database.database = Some(v);
700 }
701 if let Ok(v) = std::env::var("WAYPOINT_CONNECT_RETRIES")
702 && let Ok(n) = v.parse::<u32>()
703 {
704 self.database.connect_retries = n;
705 }
706 if let Ok(v) = std::env::var("WAYPOINT_SSL_MODE")
707 && let Ok(mode) = v.parse()
708 {
709 self.database.ssl_mode = mode;
710 }
711 if let Ok(v) = std::env::var("WAYPOINT_CONNECT_TIMEOUT")
712 && let Ok(n) = v.parse::<u32>()
713 {
714 self.database.connect_timeout_secs = n;
715 }
716 if let Ok(v) = std::env::var("WAYPOINT_STATEMENT_TIMEOUT")
717 && let Ok(n) = v.parse::<u32>()
718 {
719 self.database.statement_timeout_secs = n;
720 }
721 if let Ok(v) = std::env::var("WAYPOINT_MIGRATIONS_LOCATIONS") {
722 self.migrations.locations =
723 v.split(',').map(|s| normalize_location(s.trim())).collect();
724 }
725 if let Ok(v) = std::env::var("WAYPOINT_MIGRATIONS_TABLE") {
726 self.migrations.table = v;
727 }
728 if let Ok(v) = std::env::var("WAYPOINT_MIGRATIONS_SCHEMA") {
729 self.migrations.schema = v;
730 }
731
732 if let Ok(v) = std::env::var("WAYPOINT_DATABASE_ENGINE") {
733 match v.parse() {
734 Ok(kind) => self.database.engine = kind,
735 Err(_) => log::warn!(
736 "Invalid WAYPOINT_DATABASE_ENGINE '{}', using default 'postgres'",
737 v
738 ),
739 }
740 }
741 if let Ok(v) = std::env::var("WAYPOINT_KEEPALIVE")
742 && let Ok(n) = v.parse::<u32>()
743 {
744 self.database.keepalive_secs = n;
745 }
746 if let Ok(v) = std::env::var("WAYPOINT_BATCH_TRANSACTION") {
747 self.migrations.batch_transaction = v == "1" || v.eq_ignore_ascii_case("true");
748 }
749 if let Ok(v) = std::env::var("WAYPOINT_ENVIRONMENT") {
750 self.migrations.environment = Some(v);
751 }
752
753 for (key, value) in std::env::vars() {
755 if let Some(placeholder_key) = key.strip_prefix("WAYPOINT_PLACEHOLDER_") {
756 self.placeholders
757 .insert(placeholder_key.to_lowercase(), value);
758 }
759 }
760 }
761
762 fn apply_cli(&mut self, overrides: &CliOverrides) {
763 apply_option_some_clone!(overrides.url => self.database.url);
764 apply_option_clone!(overrides.schema => self.migrations.schema);
765 apply_option_clone!(overrides.table => self.migrations.table);
766 apply_option_clone!(overrides.locations => self.migrations.locations);
767 apply_option!(overrides.out_of_order => self.migrations.out_of_order);
768 apply_option!(overrides.validate_on_migrate => self.migrations.validate_on_migrate);
769 apply_option_clone!(overrides.baseline_version => self.migrations.baseline_version);
770 apply_option!(overrides.connect_retries => self.database.connect_retries);
771 if let Some(ref v) = overrides.ssl_mode {
772 if let Ok(mode) = v.parse() {
774 self.database.ssl_mode = mode;
775 }
776 }
777 apply_option!(overrides.connect_timeout => self.database.connect_timeout_secs);
778 apply_option!(overrides.statement_timeout => self.database.statement_timeout_secs);
779 apply_option_some_clone!(overrides.environment => self.migrations.environment);
780 apply_option!(overrides.dependency_ordering => self.migrations.dependency_ordering);
781 apply_option!(overrides.keepalive => self.database.keepalive_secs);
782 apply_option!(overrides.batch_transaction => self.migrations.batch_transaction);
783 }
784
785 pub fn connection_string(&self) -> Result<String> {
797 if let Some(ref url) = self.database.url {
798 return Ok(normalize_jdbc_url(url));
799 }
800
801 let engine = self.database.engine;
802 let host = self.database.host.as_deref().unwrap_or("localhost");
803 let default_port = match engine {
804 crate::dialect::DialectKind::Postgres => 5432,
805 crate::dialect::DialectKind::Mysql => 3306,
806 };
807 let port = self.database.port.unwrap_or(default_port);
808 let user =
809 self.database.user.as_deref().ok_or_else(|| {
810 WaypointError::ConfigError("Database user is required".to_string())
811 })?;
812 let database =
813 self.database.database.as_deref().ok_or_else(|| {
814 WaypointError::ConfigError("Database name is required".to_string())
815 })?;
816
817 match engine {
818 crate::dialect::DialectKind::Mysql => {
819 let auth = match self.database.password {
824 Some(ref password) => format!(
825 "{}:{}",
826 percent_encode_userinfo(user),
827 percent_encode_userinfo(password)
828 ),
829 None => percent_encode_userinfo(user),
830 };
831 Ok(format!("mysql://{}@{}:{}/{}", auth, host, port, database))
832 }
833 crate::dialect::DialectKind::Postgres => {
834 let mut url = format!(
835 "host={} port={} user={} dbname={}",
836 host, port, user, database
837 );
838 if let Some(ref password) = self.database.password {
839 let escaped = password.replace('\\', "\\\\").replace('\'', "\\'");
841 url.push_str(&format!(" password='{}'", escaped));
842 }
843 Ok(url)
844 }
845 }
846 }
847}
848
849fn percent_encode_userinfo(s: &str) -> String {
856 let mut out = String::with_capacity(s.len());
857 for b in s.bytes() {
858 match b {
859 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
860 out.push(b as char)
861 }
862 _ => out.push_str(&format!("%{:02X}", b)),
863 }
864 }
865 out
866}
867
868fn normalize_jdbc_url(url: &str) -> String {
879 let url = url.strip_prefix("jdbc:").unwrap_or(url);
881
882 if let Some((base, query)) = url.split_once('?') {
884 let mut user = None;
885 let mut password = None;
886 let mut other_params = Vec::new();
887
888 for param in query.split('&') {
889 if let Some((key, value)) = param.split_once('=') {
890 match key.to_lowercase().as_str() {
891 "user" => user = Some(value.to_string()),
892 "password" => password = Some(value.to_string()),
893 _ => other_params.push(param.to_string()),
894 }
895 }
896 }
897
898 if (user.is_some() || password.is_some())
900 && let Some(rest) = base
901 .strip_prefix("postgresql://")
902 .or_else(|| base.strip_prefix("postgres://"))
903 {
904 let scheme = if base.starts_with("postgresql://") {
905 "postgresql"
906 } else {
907 "postgres"
908 };
909
910 let auth = match (user, password) {
911 (Some(u), Some(p)) => format!(
912 "{}:{}@",
913 percent_encode_userinfo(&u),
914 percent_encode_userinfo(&p)
915 ),
916 (Some(u), None) => format!("{}@", percent_encode_userinfo(&u)),
917 (None, Some(p)) => format!(":{}@", percent_encode_userinfo(&p)),
918 (None, None) => String::new(),
919 };
920
921 let mut result = format!("{}://{}{}", scheme, auth, rest);
922 if !other_params.is_empty() {
923 result.push('?');
924 result.push_str(&other_params.join("&"));
925 }
926 return result;
927 }
928
929 if other_params.is_empty() {
931 return base.to_string();
932 }
933 return format!("{}?{}", base, other_params.join("&"));
934 }
935
936 url.to_string()
937}
938
939pub fn normalize_location(location: &str) -> PathBuf {
941 let stripped = location.strip_prefix("filesystem:").unwrap_or(location);
942 PathBuf::from(stripped)
943}
944
945#[cfg(test)]
946mod tests {
947 use super::*;
948
949 #[test]
950 fn test_default_config() {
951 let config = WaypointConfig::default();
952 assert_eq!(config.migrations.table, "waypoint_schema_history");
953 assert_eq!(config.migrations.schema, "public");
954 assert!(!config.migrations.out_of_order);
955 assert!(config.migrations.validate_on_migrate);
956 assert!(!config.migrations.clean_enabled);
957 assert_eq!(config.migrations.baseline_version, "1");
958 assert_eq!(
959 config.migrations.locations,
960 vec![PathBuf::from("db/migrations")]
961 );
962 }
963
964 #[test]
965 fn test_connection_string_from_url() {
966 let mut config = WaypointConfig::default();
967 config.database.url = Some("postgres://user:pass@localhost/db".to_string());
968 assert_eq!(
969 config.connection_string().unwrap(),
970 "postgres://user:pass@localhost/db"
971 );
972 }
973
974 #[test]
975 fn test_connection_string_from_fields() {
976 let mut config = WaypointConfig::default();
977 config.database.host = Some("myhost".to_string());
978 config.database.port = Some(5433);
979 config.database.user = Some("myuser".to_string());
980 config.database.database = Some("mydb".to_string());
981 config.database.password = Some("secret".to_string());
982
983 let conn = config.connection_string().unwrap();
984 assert!(conn.contains("host=myhost"));
985 assert!(conn.contains("port=5433"));
986 assert!(conn.contains("user=myuser"));
987 assert!(conn.contains("dbname=mydb"));
988 assert!(conn.contains("password='secret'"));
989 }
990
991 #[test]
992 fn test_connection_string_missing_user() {
993 let mut config = WaypointConfig::default();
994 config.database.database = Some("mydb".to_string());
995 assert!(config.connection_string().is_err());
996 }
997
998 #[test]
999 fn test_cli_overrides() {
1000 let mut config = WaypointConfig::default();
1001 let overrides = CliOverrides {
1002 url: Some("postgres://override@localhost/db".to_string()),
1003 schema: Some("custom_schema".to_string()),
1004 table: Some("custom_table".to_string()),
1005 locations: Some(vec![PathBuf::from("custom/path")]),
1006 out_of_order: Some(true),
1007 validate_on_migrate: Some(false),
1008 baseline_version: Some("5".to_string()),
1009 connect_retries: None,
1010 ssl_mode: None,
1011 connect_timeout: None,
1012 statement_timeout: None,
1013 environment: None,
1014 dependency_ordering: None,
1015 keepalive: None,
1016 batch_transaction: None,
1017 };
1018
1019 config.apply_cli(&overrides);
1020
1021 assert_eq!(
1022 config.database.url.as_deref(),
1023 Some("postgres://override@localhost/db")
1024 );
1025 assert_eq!(config.migrations.schema, "custom_schema");
1026 assert_eq!(config.migrations.table, "custom_table");
1027 assert_eq!(
1028 config.migrations.locations,
1029 vec![PathBuf::from("custom/path")]
1030 );
1031 assert!(config.migrations.out_of_order);
1032 assert!(!config.migrations.validate_on_migrate);
1033 assert_eq!(config.migrations.baseline_version, "5");
1034 }
1035
1036 #[test]
1037 fn test_toml_parsing() {
1038 let toml_str = r#"
1039[database]
1040url = "postgres://user:pass@localhost/mydb"
1041
1042[migrations]
1043table = "my_history"
1044schema = "app"
1045out_of_order = true
1046locations = ["sql/migrations", "sql/seeds"]
1047
1048[placeholders]
1049env = "production"
1050app_name = "myapp"
1051"#;
1052
1053 let toml_config: TomlConfig = toml::from_str(toml_str).unwrap();
1054 let mut config = WaypointConfig::default();
1055 config.apply_toml(toml_config);
1056
1057 assert_eq!(
1058 config.database.url.as_deref(),
1059 Some("postgres://user:pass@localhost/mydb")
1060 );
1061 assert_eq!(config.migrations.table, "my_history");
1062 assert_eq!(config.migrations.schema, "app");
1063 assert!(config.migrations.out_of_order);
1064 assert_eq!(
1065 config.migrations.locations,
1066 vec![PathBuf::from("sql/migrations"), PathBuf::from("sql/seeds")]
1067 );
1068 assert_eq!(config.placeholders.get("env").unwrap(), "production");
1069 assert_eq!(config.placeholders.get("app_name").unwrap(), "myapp");
1070 }
1071
1072 #[test]
1073 fn test_normalize_jdbc_url_with_credentials() {
1074 let url = "jdbc:postgresql://myhost:5432/mydb?user=admin&password=secret";
1075 assert_eq!(
1076 normalize_jdbc_url(url),
1077 "postgresql://admin:secret@myhost:5432/mydb"
1078 );
1079 }
1080
1081 #[test]
1082 fn test_normalize_jdbc_url_user_only() {
1083 let url = "jdbc:postgresql://myhost:5432/mydb?user=admin";
1084 assert_eq!(
1085 normalize_jdbc_url(url),
1086 "postgresql://admin@myhost:5432/mydb"
1087 );
1088 }
1089
1090 #[test]
1091 fn test_normalize_jdbc_url_strips_jdbc_prefix() {
1092 let url = "jdbc:postgresql://myhost:5432/mydb";
1093 assert_eq!(normalize_jdbc_url(url), "postgresql://myhost:5432/mydb");
1094 }
1095
1096 #[test]
1097 fn test_normalize_jdbc_url_passthrough() {
1098 let url = "postgresql://user:pass@myhost:5432/mydb";
1099 assert_eq!(normalize_jdbc_url(url), url);
1100 }
1101
1102 #[test]
1103 fn test_normalize_jdbc_url_preserves_other_params() {
1104 let url = "jdbc:postgresql://myhost:5432/mydb?user=admin&password=secret&sslmode=require";
1105 assert_eq!(
1106 normalize_jdbc_url(url),
1107 "postgresql://admin:secret@myhost:5432/mydb?sslmode=require"
1108 );
1109 }
1110
1111 #[test]
1112 fn test_normalize_location_filesystem_prefix() {
1113 assert_eq!(
1114 normalize_location("filesystem:/flyway/sql"),
1115 PathBuf::from("/flyway/sql")
1116 );
1117 }
1118
1119 #[test]
1120 fn test_normalize_location_plain_path() {
1121 assert_eq!(
1122 normalize_location("/my/migrations"),
1123 PathBuf::from("/my/migrations")
1124 );
1125 }
1126
1127 #[test]
1128 fn test_normalize_location_relative() {
1129 assert_eq!(
1130 normalize_location("filesystem:db/migrations"),
1131 PathBuf::from("db/migrations")
1132 );
1133 }
1134
1135 #[test]
1136 fn test_connection_string_password_special_chars() {
1137 let config = WaypointConfig {
1138 database: DatabaseConfig {
1139 host: Some("localhost".to_string()),
1140 port: Some(5432),
1141 user: Some("admin".to_string()),
1142 database: Some("mydb".to_string()),
1143 password: Some("p@ss'w ord".to_string()),
1144 ..Default::default()
1145 },
1146 ..Default::default()
1147 };
1148 let conn = config.connection_string().unwrap();
1149 assert!(conn.contains("password='p@ss\\'w ord'"));
1150 }
1151
1152 #[test]
1153 fn test_connection_string_mysql_from_fields() {
1154 let config = WaypointConfig {
1155 database: DatabaseConfig {
1156 engine: crate::dialect::DialectKind::Mysql,
1157 host: Some("db.internal".to_string()),
1158 user: Some("app".to_string()),
1159 password: Some("s3cr3t".to_string()),
1160 database: Some("shop".to_string()),
1161 ..Default::default()
1162 },
1163 ..Default::default()
1164 };
1165 assert_eq!(
1167 config.connection_string().unwrap(),
1168 "mysql://app:s3cr3t@db.internal:3306/shop"
1169 );
1170 assert_eq!(
1171 crate::dialect::DialectKind::from_url(&config.connection_string().unwrap()),
1172 Some(crate::dialect::DialectKind::Mysql)
1173 );
1174 }
1175
1176 #[test]
1177 fn test_connection_string_mysql_percent_encodes_password() {
1178 let config = WaypointConfig {
1179 database: DatabaseConfig {
1180 engine: crate::dialect::DialectKind::Mysql,
1181 host: Some("h".to_string()),
1182 port: Some(13306),
1183 user: Some("u".to_string()),
1184 password: Some("p@ss/word".to_string()),
1185 database: Some("d".to_string()),
1186 ..Default::default()
1187 },
1188 ..Default::default()
1189 };
1190 assert_eq!(
1191 config.connection_string().unwrap(),
1192 "mysql://u:p%40ss%2Fword@h:13306/d"
1193 );
1194 }
1195
1196 #[test]
1197 fn test_normalize_jdbc_url_percent_encodes_credentials() {
1198 let url = "jdbc:postgresql://myhost:5432/mydb?user=adm%69n&password=p@ss";
1200 assert_eq!(
1201 normalize_jdbc_url(url),
1202 "postgresql://adm%2569n:p%40ss@myhost:5432/mydb"
1203 );
1204 }
1205
1206 #[test]
1207 fn test_engine_defaults_to_postgres() {
1208 let config = WaypointConfig::default();
1209 assert_eq!(
1210 config.database.engine,
1211 crate::dialect::DialectKind::Postgres
1212 );
1213 }
1214
1215 #[test]
1216 fn test_toml_engine_key() {
1217 let toml_str = r#"
1218[database]
1219engine = "mysql"
1220host = "localhost"
1221user = "root"
1222database = "app"
1223"#;
1224 let toml_config: TomlConfig = toml::from_str(toml_str).unwrap();
1225 let mut config = WaypointConfig::default();
1226 config.apply_toml(toml_config);
1227 assert_eq!(config.database.engine, crate::dialect::DialectKind::Mysql);
1228 assert!(config.connection_string().unwrap().starts_with("mysql://"));
1229 }
1230}