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}
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/// Hook configuration for running SQL before/after migrations.
181#[derive(Debug, Clone, Default)]
182pub struct HooksConfig {
183    /// SQL scripts to run once before the entire migration run.
184    pub before_migrate: Vec<PathBuf>,
185    /// SQL scripts to run once after the entire migration run.
186    pub after_migrate: Vec<PathBuf>,
187    /// SQL scripts to run before each individual migration.
188    pub before_each_migrate: Vec<PathBuf>,
189    /// SQL scripts to run after each individual migration.
190    pub after_each_migrate: Vec<PathBuf>,
191}
192
193/// Lint configuration.
194#[derive(Debug, Clone, Default)]
195pub struct LintConfig {
196    /// List of lint rule names to disable.
197    pub disabled_rules: Vec<String>,
198}
199
200/// Migration behavior settings.
201#[derive(Debug, Clone)]
202pub struct MigrationSettings {
203    /// Filesystem directories to scan for migration SQL files.
204    pub locations: Vec<PathBuf>,
205    /// Name of the schema history table.
206    pub table: String,
207    /// Database schema where the history table resides.
208    pub schema: String,
209    /// Whether to allow applying migrations with versions below the highest applied version.
210    pub out_of_order: bool,
211    /// Whether to validate already-applied migration checksums before migrating.
212    pub validate_on_migrate: bool,
213    /// Whether the `clean` command is allowed to run.
214    pub clean_enabled: bool,
215    /// Version to use when running the `baseline` command.
216    pub baseline_version: String,
217    /// Custom value for the `installed_by` column (defaults to database user).
218    pub installed_by: Option<String>,
219    /// Logical environment name (e.g., "production", "staging") for filtering.
220    pub environment: Option<String>,
221    /// Whether to use `@depends` directives to order migrations topologically.
222    pub dependency_ordering: bool,
223    /// Whether to display a progress indicator during migration.
224    pub show_progress: bool,
225    /// Whether to wrap all pending migrations in a single transaction (all-or-nothing).
226    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/// Migration simulation configuration.
249#[derive(Debug, Clone, Default)]
250pub struct SimulationConfig {
251    /// Whether to run simulation before migrate.
252    pub simulate_before_migrate: bool,
253}
254
255// ── TOML deserialization structs ──
256
257#[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/// CLI overrides that take highest priority.
376#[derive(Debug, Default, Clone)]
377pub struct CliOverrides {
378    /// Override database connection URL.
379    pub url: Option<String>,
380    /// Override the database schema for the history table.
381    pub schema: Option<String>,
382    /// Override the schema history table name.
383    pub table: Option<String>,
384    /// Override migration file locations.
385    pub locations: Option<Vec<PathBuf>>,
386    /// Override whether out-of-order migrations are allowed.
387    pub out_of_order: Option<bool>,
388    /// Override whether to validate checksums on migrate.
389    pub validate_on_migrate: Option<bool>,
390    /// Override the baseline version string.
391    pub baseline_version: Option<String>,
392    /// Override the number of connection retries.
393    pub connect_retries: Option<u32>,
394    /// Override the SSL/TLS connection mode.
395    pub ssl_mode: Option<String>,
396    /// Override the connection timeout in seconds.
397    pub connect_timeout: Option<u32>,
398    /// Override the statement timeout in seconds.
399    pub statement_timeout: Option<u32>,
400    /// Override the logical environment name.
401    pub environment: Option<String>,
402    /// Override whether to use dependency-based migration ordering.
403    pub dependency_ordering: Option<bool>,
404    /// Override TCP keepalive interval in seconds.
405    pub keepalive: Option<u32>,
406    /// Override batch transaction mode (all-or-nothing).
407    pub batch_transaction: Option<bool>,
408}
409
410impl WaypointConfig {
411    /// Load configuration with the following priority (highest wins):
412    /// 1. CLI arguments
413    /// 2. Environment variables
414    /// 3. TOML config file
415    /// 4. Built-in defaults
416    pub fn load(config_path: Option<&str>, overrides: &CliOverrides) -> Result<Self> {
417        let mut config = WaypointConfig::default();
418
419        // Layer 3: TOML config file
420        let toml_path = config_path.unwrap_or("waypoint.toml");
421        if let Ok(content) = std::fs::read_to_string(toml_path) {
422            // Warn if config file has overly permissive permissions (Unix only)
423            #[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            // If explicitly specified, error if not found
442            return Err(WaypointError::ConfigError(format!(
443                "Config file '{}' not found",
444                toml_path
445            )));
446        }
447
448        // Layer 2: Environment variables
449        config.apply_env();
450
451        // Layer 1: CLI overrides
452        config.apply_cli(overrides);
453
454        // Validate identifiers
455        crate::db::validate_identifier(&config.migrations.schema)?;
456        crate::db::validate_identifier(&config.migrations.table)?;
457
458        // Cap connect_retries at 20
459        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                // Check for per-database env var
587                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        // Scan for placeholder env vars: WAYPOINT_PLACEHOLDER_{KEY}
707        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            // Ignore parse errors here — they'll be caught in validation
726            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    /// Build a connection string from the config.
739    /// Prefers `url` if set; otherwise builds from individual fields.
740    /// Handles JDBC-style URLs by stripping the `jdbc:` prefix and
741    /// extracting `user` and `password` query parameters.
742    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            // Quote password to handle special characters (spaces, quotes, etc.)
765            let escaped = password.replace('\\', "\\\\").replace('\'', "\\'");
766            url.push_str(&format!(" password='{}'", escaped));
767        }
768
769        Ok(url)
770    }
771}
772
773/// Normalize a JDBC-style URL to a standard PostgreSQL connection string.
774///
775/// Handles:
776///   - `jdbc:postgresql://host:port/db?user=x&password=y`  →  `postgresql://x:y@host:port/db`
777///   - `postgresql://...` passed through as-is
778///   - `postgres://...` passed through as-is
779fn normalize_jdbc_url(url: &str) -> String {
780    // Strip jdbc: prefix
781    let url = url.strip_prefix("jdbc:").unwrap_or(url);
782
783    // Parse query parameters for user/password if present
784    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 we extracted user/password, rebuild the URL with credentials in the authority
800        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        // No user/password in query, return with jdbc: stripped
828        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
837/// Strip `filesystem:` prefix from a location path (Flyway compatibility).
838pub 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}