Skip to main content

waypoint_core/
config.rs

1//! Configuration loading and resolution.
2//!
3//! Supports TOML config files, environment variables, and CLI overrides
4//! with a defined priority order (CLI > env > TOML > defaults).
5
6use std::collections::HashMap;
7use std::fmt;
8use std::path::PathBuf;
9
10use serde::Deserialize;
11
12use crate::error::{Result, WaypointError};
13
14/// Helper macro to apply an optional owned value directly to a target field.
15///
16/// Replaces: `if let Some(v) = $opt { $target = v; }`
17macro_rules! apply_option {
18    ($opt:expr => $target:expr) => {
19        if let Some(v) = $opt {
20            $target = v;
21        }
22    };
23}
24
25/// Helper macro to apply an optional owned value, wrapping it in `Some()`.
26///
27/// Replaces: `if let Some(v) = $opt { $target = Some(v); }`
28macro_rules! apply_option_some {
29    ($opt:expr => $target:expr) => {
30        if let Some(v) = $opt {
31            $target = Some(v);
32        }
33    };
34}
35
36/// Helper macro to clone a borrowed optional value directly to a target field.
37///
38/// Replaces: `if let Some(ref v) = $opt { $target = v.clone(); }`
39macro_rules! apply_option_clone {
40    ($opt:expr => $target:expr) => {
41        if let Some(ref v) = $opt {
42            $target = v.clone();
43        }
44    };
45}
46
47/// Helper macro to clone a borrowed optional value, wrapping it in `Some()`.
48///
49/// Replaces: `if let Some(ref v) = $opt { $target = Some(v.clone()); }`
50macro_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/// SSL/TLS connection mode.
59#[derive(Debug, Clone, Default, PartialEq, Eq)]
60pub enum SslMode {
61    /// Never use TLS (current default behavior).
62    Disable,
63    /// Try TLS first, fall back to plaintext.
64    #[default]
65    Prefer,
66    /// Require TLS — fail if handshake fails.
67    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/// Top-level configuration for Waypoint.
87#[derive(Debug, Clone, Default)]
88pub struct WaypointConfig {
89    /// Database connection settings (URL, host, port, credentials, etc.).
90    pub database: DatabaseConfig,
91    /// Migration behavior settings (locations, table name, ordering, etc.).
92    pub migrations: MigrationSettings,
93    /// SQL callback hook configuration for before/after migration phases.
94    pub hooks: HooksConfig,
95    /// Key-value placeholder substitutions applied to migration SQL.
96    pub placeholders: HashMap<String, String>,
97    /// Lint rule configuration.
98    pub lint: LintConfig,
99    /// Schema snapshot configuration for drift detection.
100    pub snapshots: crate::commands::snapshot::SnapshotConfig,
101    /// Pre-flight check configuration run before migrations.
102    pub preflight: crate::preflight::PreflightConfig,
103    /// Optional multi-database configuration for parallel migration targets.
104    pub multi_database: Option<Vec<crate::multi::NamedDatabaseConfig>>,
105    /// Guard (pre/post condition) configuration.
106    pub guards: crate::guard::GuardsConfig,
107    /// Auto-reversal generation configuration.
108    pub reversals: crate::reversal::ReversalConfig,
109    /// Safety analysis configuration.
110    pub safety: crate::safety::SafetyConfig,
111    /// Schema advisor configuration.
112    pub advisor: crate::advisor::AdvisorConfig,
113    /// Migration simulation configuration.
114    pub simulation: SimulationConfig,
115}
116
117/// Database connection configuration.
118#[derive(Clone)]
119pub struct DatabaseConfig {
120    /// Full connection URL (e.g., `postgres://user:pass@host/db`).
121    pub url: Option<String>,
122    /// Database server hostname.
123    pub host: Option<String>,
124    /// Database server port number.
125    pub port: Option<u16>,
126    /// Database user for authentication.
127    pub user: Option<String>,
128    /// Database password for authentication.
129    pub password: Option<String>,
130    /// Database name to connect to.
131    pub database: Option<String>,
132    /// Number of times to retry a failed connection (max 20).
133    pub connect_retries: u32,
134    /// SSL/TLS mode for the database connection.
135    pub ssl_mode: SslMode,
136    /// Connection timeout in seconds.
137    pub connect_timeout_secs: u32,
138    /// Statement timeout in seconds (0 means no timeout).
139    pub statement_timeout_secs: u32,
140    /// TCP keepalive interval in seconds (0 disables, default 120).
141    pub keepalive_secs: u32,
142    /// Which engine the host/port/user/database fields describe.
143    ///
144    /// Only consulted when `url` is unset. With a `url`, the engine is derived
145    /// from its scheme (`postgres://` / `mysql://`). Defaults to PostgreSQL,
146    /// which is what the field-based form always produced historically.
147    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/// Hook configuration for running SQL before/after migrations.
189#[derive(Debug, Clone, Default)]
190pub struct HooksConfig {
191    /// SQL scripts to run once before the entire migration run.
192    pub before_migrate: Vec<PathBuf>,
193    /// SQL scripts to run once after the entire migration run.
194    pub after_migrate: Vec<PathBuf>,
195    /// SQL scripts to run before each individual migration.
196    pub before_each_migrate: Vec<PathBuf>,
197    /// SQL scripts to run after each individual migration.
198    pub after_each_migrate: Vec<PathBuf>,
199}
200
201/// Lint configuration.
202#[derive(Debug, Clone, Default)]
203pub struct LintConfig {
204    /// List of lint rule names to disable.
205    pub disabled_rules: Vec<String>,
206}
207
208/// Migration behavior settings.
209#[derive(Debug, Clone)]
210pub struct MigrationSettings {
211    /// Filesystem directories to scan for migration SQL files.
212    pub locations: Vec<PathBuf>,
213    /// Name of the schema history table.
214    pub table: String,
215    /// Database schema where the history table resides.
216    pub schema: String,
217    /// Whether to allow applying migrations with versions below the highest applied version.
218    pub out_of_order: bool,
219    /// Whether to validate already-applied migration checksums before migrating.
220    pub validate_on_migrate: bool,
221    /// Whether the `clean` command is allowed to run.
222    pub clean_enabled: bool,
223    /// Version to use when running the `baseline` command.
224    pub baseline_version: String,
225    /// Custom value for the `installed_by` column (defaults to database user).
226    pub installed_by: Option<String>,
227    /// Logical environment name (e.g., "production", "staging") for filtering.
228    pub environment: Option<String>,
229    /// Whether to use `@depends` directives to order migrations topologically.
230    pub dependency_ordering: bool,
231    /// Whether to display a progress indicator during migration.
232    pub show_progress: bool,
233    /// Whether to wrap all pending migrations in a single transaction (all-or-nothing).
234    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/// Migration simulation configuration.
257#[derive(Debug, Clone, Default)]
258pub struct SimulationConfig {
259    /// Whether to run simulation before migrate.
260    pub simulate_before_migrate: bool,
261}
262
263// ── TOML deserialization structs ──
264
265#[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/// CLI overrides that take highest priority.
386#[derive(Debug, Default, Clone)]
387pub struct CliOverrides {
388    /// Override database connection URL.
389    pub url: Option<String>,
390    /// Override the database schema for the history table.
391    pub schema: Option<String>,
392    /// Override the schema history table name.
393    pub table: Option<String>,
394    /// Override migration file locations.
395    pub locations: Option<Vec<PathBuf>>,
396    /// Override whether out-of-order migrations are allowed.
397    pub out_of_order: Option<bool>,
398    /// Override whether to validate checksums on migrate.
399    pub validate_on_migrate: Option<bool>,
400    /// Override the baseline version string.
401    pub baseline_version: Option<String>,
402    /// Override the number of connection retries.
403    pub connect_retries: Option<u32>,
404    /// Override the SSL/TLS connection mode.
405    pub ssl_mode: Option<String>,
406    /// Override the connection timeout in seconds.
407    pub connect_timeout: Option<u32>,
408    /// Override the statement timeout in seconds.
409    pub statement_timeout: Option<u32>,
410    /// Override the logical environment name.
411    pub environment: Option<String>,
412    /// Override whether to use dependency-based migration ordering.
413    pub dependency_ordering: Option<bool>,
414    /// Override TCP keepalive interval in seconds.
415    pub keepalive: Option<u32>,
416    /// Override batch transaction mode (all-or-nothing).
417    pub batch_transaction: Option<bool>,
418}
419
420impl WaypointConfig {
421    /// Load configuration with the following priority (highest wins):
422    /// 1. CLI arguments
423    /// 2. Environment variables
424    /// 3. TOML config file
425    /// 4. Built-in defaults
426    pub fn load(config_path: Option<&str>, overrides: &CliOverrides) -> Result<Self> {
427        let mut config = WaypointConfig::default();
428
429        // Layer 3: TOML config file
430        let toml_path = config_path.unwrap_or("waypoint.toml");
431        if let Ok(content) = std::fs::read_to_string(toml_path) {
432            // Warn if config file has overly permissive permissions (Unix only)
433            #[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            // If explicitly specified, error if not found
456            return Err(WaypointError::ConfigError(format!(
457                "Config file '{}' not found",
458                toml_path
459            )));
460        }
461
462        // Layer 2: Environment variables
463        config.apply_env();
464
465        // Layer 1: CLI overrides
466        config.apply_cli(overrides);
467
468        // Validate identifiers
469        crate::db::validate_identifier(&config.migrations.schema)?;
470        crate::db::validate_identifier(&config.migrations.table)?;
471
472        // Cap connect_retries at 20
473        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                // Inherit the top-level `[database]` tuning (ssl_mode,
609                // timeouts, keepalive, retries) — those are transport policy
610                // that should apply to every target — while clearing the
611                // connection *identity* fields, which each entry supplies via
612                // its own `url`. `[database]` is applied above, so `self` is
613                // already populated at this point.
614                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                // Check for per-database env var
625                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        // Scan for placeholder env vars: WAYPOINT_PLACEHOLDER_{KEY}
754        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            // Ignore parse errors here — they'll be caught in validation
773            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    /// Build a connection string from the config.
786    ///
787    /// Prefers `url` if set; otherwise builds from the individual `host` /
788    /// `port` / `user` / `password` / `database` fields, in the shape the
789    /// configured [`DatabaseConfig::engine`] expects:
790    ///
791    /// - PostgreSQL → libpq key=value (`host=… port=… user=… dbname=…`)
792    /// - MySQL → a `mysql://` URL, so that engine auto-detection still works
793    ///
794    /// Handles JDBC-style URLs by stripping the `jdbc:` prefix and extracting
795    /// `user` and `password` query parameters.
796    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                // A URL, not key=value: `DialectKind::from_url` has to be able
820                // to route this to the MySQL backend. Credentials are
821                // percent-encoded so specials in the password stay inside the
822                // userinfo section.
823                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                    // Quote password to handle special characters (spaces, quotes, etc.)
840                    let escaped = password.replace('\\', "\\\\").replace('\'', "\\'");
841                    url.push_str(&format!(" password='{}'", escaped));
842                }
843                Ok(url)
844            }
845        }
846    }
847}
848
849/// Percent-encode a URL userinfo component (username or password).
850///
851/// Everything outside the RFC 3986 unreserved set is escaped, so a password
852/// containing `@`, `:`, `/`, `?` or `#` cannot break out of the userinfo
853/// section and corrupt the authority. Hand-rolled rather than pulling in a
854/// dependency for ~15 lines.
855fn 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
868/// Normalize a JDBC-style URL to a standard PostgreSQL connection string.
869///
870/// Handles:
871///   - `jdbc:postgresql://host:port/db?user=x&password=y`  →  `postgresql://x:y@host:port/db`
872///   - `postgresql://...` passed through as-is
873///   - `postgres://...` passed through as-is
874///
875/// Credentials lifted out of the query string are percent-encoded on the way
876/// into the authority; otherwise a password like `p@ss` would produce
877/// `postgres://user:p@ss@host/db`, which does not parse as intended.
878fn normalize_jdbc_url(url: &str) -> String {
879    // Strip jdbc: prefix
880    let url = url.strip_prefix("jdbc:").unwrap_or(url);
881
882    // Parse query parameters for user/password if present
883    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 we extracted user/password, rebuild the URL with credentials in the authority
899        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        // No user/password in query, return with jdbc: stripped
930        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
939/// Strip `filesystem:` prefix from a location path (Flyway compatibility).
940pub 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        // Defaults to the MySQL port and emits a URL the engine detector routes.
1166        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        // An unencoded `@` in the password would break the authority.
1199        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}