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///
60/// These are libpq's `sslmode` values and carry libpq's meanings. In
61/// particular `Require` encrypts but does **not** authenticate the server —
62/// use [`SslMode::VerifyFull`] if you want the certificate chain and hostname
63/// checked. libpq's `allow` is deliberately not supported; see `FromStr`.
64#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
65pub enum SslMode {
66    /// Never use TLS.
67    Disable,
68    /// Try TLS first (without verifying the certificate), fall back to plaintext.
69    #[default]
70    Prefer,
71    /// Require TLS, but do not verify the server certificate.
72    Require,
73    /// Require TLS and verify the certificate chain, but not the hostname.
74    VerifyCa,
75    /// Require TLS and verify both the certificate chain and the hostname.
76    VerifyFull,
77}
78
79impl SslMode {
80    /// Whether this mode verifies the server certificate chain at all.
81    pub fn verifies_certificate(&self) -> bool {
82        matches!(self, SslMode::VerifyCa | SslMode::VerifyFull)
83    }
84
85    /// Whether TLS is mandatory — i.e. a plaintext connection is not acceptable.
86    pub fn requires_tls(&self) -> bool {
87        matches!(
88            self,
89            SslMode::Require | SslMode::VerifyCa | SslMode::VerifyFull
90        )
91    }
92
93    /// The canonical libpq spelling, for log and error messages.
94    pub fn as_str(&self) -> &'static str {
95        match self {
96            SslMode::Disable => "disable",
97            SslMode::Prefer => "prefer",
98            SslMode::Require => "require",
99            SslMode::VerifyCa => "verify-ca",
100            SslMode::VerifyFull => "verify-full",
101        }
102    }
103}
104
105impl fmt::Display for SslMode {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        f.write_str(self.as_str())
108    }
109}
110
111impl std::str::FromStr for SslMode {
112    type Err = WaypointError;
113
114    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
115        // Accept `-`, `_` and bare spellings so `verify-ca`, `verify_ca` and
116        // `verifyca` all land in the same place.
117        let normalized = s.to_lowercase().replace(['-', '_'], "");
118        match normalized.as_str() {
119            "disable" | "disabled" => Ok(SslMode::Disable),
120            "prefer" => Ok(SslMode::Prefer),
121            "require" | "required" => Ok(SslMode::Require),
122            "verifyca" => Ok(SslMode::VerifyCa),
123            "verifyfull" => Ok(SslMode::VerifyFull),
124            // libpq's `allow` tries plaintext *first* and only then TLS. We do
125            // not implement it, and aliasing it to `prefer` would quietly give
126            // the opposite preference order, so reject it explicitly.
127            "allow" => Err(WaypointError::ConfigError(
128                "SSL mode 'allow' is not supported. Use 'prefer' to try TLS first \
129                 and fall back to plaintext."
130                    .to_string(),
131            )),
132            _ => Err(WaypointError::ConfigError(format!(
133                "Invalid SSL mode '{}'. Use 'disable', 'prefer', 'require', \
134                 'verify-ca', or 'verify-full'.",
135                s
136            ))),
137        }
138    }
139}
140
141/// Apply an `ssl_mode` string from one of the config layers, warning rather
142/// than silently discarding a value that does not parse.
143///
144/// All three layers (TOML, env, CLI) route through here. The env and CLI paths
145/// used to drop bad values with no message at all, which meant a typo in
146/// `WAYPOINT_SSL_MODE` downgraded you to `prefer` invisibly.
147fn apply_ssl_mode(target: &mut SslMode, value: &str, source: &str) {
148    match value.parse() {
149        Ok(mode) => *target = mode,
150        Err(e) => log::warn!("{} (from {}); keeping '{}'.", e, source, target),
151    }
152}
153
154/// Top-level configuration for Waypoint.
155#[derive(Debug, Clone, Default)]
156pub struct WaypointConfig {
157    /// Database connection settings (URL, host, port, credentials, etc.).
158    pub database: DatabaseConfig,
159    /// Migration behavior settings (locations, table name, ordering, etc.).
160    pub migrations: MigrationSettings,
161    /// SQL callback hook configuration for before/after migration phases.
162    pub hooks: HooksConfig,
163    /// Key-value placeholder substitutions applied to migration SQL.
164    pub placeholders: HashMap<String, String>,
165    /// Lint rule configuration.
166    pub lint: LintConfig,
167    /// Schema snapshot configuration for drift detection.
168    pub snapshots: crate::commands::snapshot::SnapshotConfig,
169    /// Pre-flight check configuration run before migrations.
170    pub preflight: crate::preflight::PreflightConfig,
171    /// Optional multi-database configuration for parallel migration targets.
172    pub multi_database: Option<Vec<crate::multi::NamedDatabaseConfig>>,
173    /// Guard (pre/post condition) configuration.
174    pub guards: crate::guard::GuardsConfig,
175    /// Auto-reversal generation configuration.
176    pub reversals: crate::reversal::ReversalConfig,
177    /// Safety analysis configuration.
178    pub safety: crate::safety::SafetyConfig,
179    /// Schema advisor configuration.
180    pub advisor: crate::advisor::AdvisorConfig,
181    /// Migration simulation configuration.
182    pub simulation: SimulationConfig,
183}
184
185/// Database connection configuration.
186#[derive(Clone)]
187pub struct DatabaseConfig {
188    /// Full connection URL (e.g., `postgres://user:pass@host/db`).
189    pub url: Option<String>,
190    /// Database server hostname.
191    pub host: Option<String>,
192    /// Database server port number.
193    pub port: Option<u16>,
194    /// Database user for authentication.
195    pub user: Option<String>,
196    /// Database password for authentication.
197    pub password: Option<String>,
198    /// Database name to connect to.
199    pub database: Option<String>,
200    /// Number of times to retry a failed connection (max 20).
201    pub connect_retries: u32,
202    /// SSL/TLS mode for the database connection.
203    pub ssl_mode: SslMode,
204    /// PEM file holding the CA certificate(s) used to verify the server.
205    ///
206    /// Mirrors libpq's `sslrootcert`: when set, these certificates **replace**
207    /// the built-in Mozilla trust store rather than adding to it. Only
208    /// consulted by the verifying modes (`verify-ca` / `verify-full`).
209    pub ssl_root_cert: Option<PathBuf>,
210    /// Connection timeout in seconds.
211    pub connect_timeout_secs: u32,
212    /// Statement timeout in seconds (0 means no timeout).
213    pub statement_timeout_secs: u32,
214    /// TCP keepalive interval in seconds (0 disables, default 120).
215    pub keepalive_secs: u32,
216    /// Which engine the host/port/user/database fields describe.
217    ///
218    /// Only consulted when `url` is unset. With a `url`, the engine is derived
219    /// from its scheme (`postgres://` / `mysql://`). Defaults to PostgreSQL,
220    /// which is what the field-based form always produced historically.
221    pub engine: crate::dialect::DialectKind,
222}
223
224impl Default for DatabaseConfig {
225    fn default() -> Self {
226        Self {
227            url: None,
228            host: None,
229            port: None,
230            user: None,
231            password: None,
232            database: None,
233            connect_retries: 0,
234            ssl_mode: SslMode::Prefer,
235            ssl_root_cert: None,
236            connect_timeout_secs: 30,
237            statement_timeout_secs: 0,
238            keepalive_secs: 120,
239            engine: crate::dialect::DialectKind::Postgres,
240        }
241    }
242}
243
244impl fmt::Debug for DatabaseConfig {
245    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246        f.debug_struct("DatabaseConfig")
247            .field("url", &self.url.as_ref().map(|_| "[REDACTED]"))
248            .field("host", &self.host)
249            .field("port", &self.port)
250            .field("user", &self.user)
251            .field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
252            .field("database", &self.database)
253            .field("connect_retries", &self.connect_retries)
254            .field("ssl_mode", &self.ssl_mode)
255            // A CA path is not a secret, so it prints plainly — knowing which
256            // trust anchor was in play is the first thing you want when
257            // debugging a handshake failure.
258            .field("ssl_root_cert", &self.ssl_root_cert)
259            .field("connect_timeout_secs", &self.connect_timeout_secs)
260            .field("statement_timeout_secs", &self.statement_timeout_secs)
261            .field("keepalive_secs", &self.keepalive_secs)
262            .field("engine", &self.engine)
263            .finish()
264    }
265}
266
267/// Hook configuration for running SQL before/after migrations.
268#[derive(Debug, Clone, Default)]
269pub struct HooksConfig {
270    /// SQL scripts to run once before the entire migration run.
271    pub before_migrate: Vec<PathBuf>,
272    /// SQL scripts to run once after the entire migration run.
273    pub after_migrate: Vec<PathBuf>,
274    /// SQL scripts to run before each individual migration.
275    pub before_each_migrate: Vec<PathBuf>,
276    /// SQL scripts to run after each individual migration.
277    pub after_each_migrate: Vec<PathBuf>,
278}
279
280/// Lint configuration.
281#[derive(Debug, Clone, Default)]
282pub struct LintConfig {
283    /// List of lint rule names to disable.
284    pub disabled_rules: Vec<String>,
285}
286
287/// Migration behavior settings.
288#[derive(Debug, Clone)]
289pub struct MigrationSettings {
290    /// Filesystem directories to scan for migration SQL files.
291    pub locations: Vec<PathBuf>,
292    /// Name of the schema history table.
293    pub table: String,
294    /// Database schema where the history table resides.
295    pub schema: String,
296    /// Whether to allow applying migrations with versions below the highest applied version.
297    pub out_of_order: bool,
298    /// Whether to validate already-applied migration checksums before migrating.
299    pub validate_on_migrate: bool,
300    /// Whether the `clean` command is allowed to run.
301    pub clean_enabled: bool,
302    /// Version to use when running the `baseline` command.
303    pub baseline_version: String,
304    /// Custom value for the `installed_by` column (defaults to database user).
305    pub installed_by: Option<String>,
306    /// Logical environment name (e.g., "production", "staging") for filtering.
307    pub environment: Option<String>,
308    /// Whether to use `@depends` directives to order migrations topologically.
309    pub dependency_ordering: bool,
310    /// Whether to display a progress indicator during migration.
311    pub show_progress: bool,
312    /// Whether to wrap all pending migrations in a single transaction (all-or-nothing).
313    pub batch_transaction: bool,
314}
315
316impl Default for MigrationSettings {
317    fn default() -> Self {
318        Self {
319            locations: vec![PathBuf::from("db/migrations")],
320            table: "waypoint_schema_history".to_string(),
321            schema: "public".to_string(),
322            out_of_order: false,
323            validate_on_migrate: true,
324            clean_enabled: false,
325            baseline_version: "1".to_string(),
326            installed_by: None,
327            environment: None,
328            dependency_ordering: false,
329            show_progress: true,
330            batch_transaction: false,
331        }
332    }
333}
334
335/// Migration simulation configuration.
336#[derive(Debug, Clone, Default)]
337pub struct SimulationConfig {
338    /// Whether to run simulation before migrate.
339    pub simulate_before_migrate: bool,
340}
341
342// ── TOML deserialization structs ──
343
344#[derive(Deserialize, Default)]
345struct TomlConfig {
346    database: Option<TomlDatabaseConfig>,
347    migrations: Option<TomlMigrationSettings>,
348    hooks: Option<TomlHooksConfig>,
349    placeholders: Option<HashMap<String, String>>,
350    lint: Option<TomlLintConfig>,
351    snapshots: Option<TomlSnapshotConfig>,
352    preflight: Option<TomlPreflightConfig>,
353    databases: Option<Vec<TomlNamedDatabaseConfig>>,
354    guards: Option<TomlGuardsConfig>,
355    reversals: Option<TomlReversalConfig>,
356    safety: Option<TomlSafetyConfig>,
357    advisor: Option<TomlAdvisorConfig>,
358    simulation: Option<TomlSimulationConfig>,
359}
360
361#[derive(Deserialize, Default)]
362struct TomlDatabaseConfig {
363    url: Option<String>,
364    host: Option<String>,
365    port: Option<u16>,
366    user: Option<String>,
367    password: Option<String>,
368    database: Option<String>,
369    connect_retries: Option<u32>,
370    ssl_mode: Option<String>,
371    ssl_root_cert: Option<String>,
372    connect_timeout: Option<u32>,
373    statement_timeout: Option<u32>,
374    keepalive: Option<u32>,
375    engine: Option<String>,
376}
377
378#[derive(Deserialize, Default)]
379struct TomlMigrationSettings {
380    locations: Option<Vec<String>>,
381    table: Option<String>,
382    schema: Option<String>,
383    out_of_order: Option<bool>,
384    validate_on_migrate: Option<bool>,
385    clean_enabled: Option<bool>,
386    baseline_version: Option<String>,
387    installed_by: Option<String>,
388    environment: Option<String>,
389    dependency_ordering: Option<bool>,
390    show_progress: Option<bool>,
391    batch_transaction: Option<bool>,
392}
393
394#[derive(Deserialize, Default)]
395struct TomlLintConfig {
396    disabled_rules: Option<Vec<String>>,
397}
398
399#[derive(Deserialize, Default)]
400struct TomlSnapshotConfig {
401    directory: Option<String>,
402    auto_snapshot_on_migrate: Option<bool>,
403    max_snapshots: Option<usize>,
404    strip_definer_mysql: Option<bool>,
405}
406
407#[derive(Deserialize, Default)]
408struct TomlPreflightConfig {
409    enabled: Option<bool>,
410    max_replication_lag_mb: Option<i64>,
411    max_replication_lag_secs: Option<i64>,
412    long_query_threshold_secs: Option<i64>,
413}
414
415#[derive(Deserialize, Default)]
416struct TomlNamedDatabaseConfig {
417    name: Option<String>,
418    url: Option<String>,
419    depends_on: Option<Vec<String>>,
420    migrations: Option<TomlMigrationSettings>,
421    hooks: Option<TomlHooksConfig>,
422    placeholders: Option<HashMap<String, String>>,
423}
424
425#[derive(Deserialize, Default)]
426struct TomlHooksConfig {
427    before_migrate: Option<Vec<String>>,
428    after_migrate: Option<Vec<String>>,
429    before_each_migrate: Option<Vec<String>>,
430    after_each_migrate: Option<Vec<String>>,
431}
432
433#[derive(Deserialize, Default)]
434struct TomlGuardsConfig {
435    enabled: Option<bool>,
436    on_require_fail: Option<String>,
437}
438
439#[derive(Deserialize, Default)]
440struct TomlReversalConfig {
441    enabled: Option<bool>,
442    warn_data_loss: Option<bool>,
443}
444
445#[derive(Deserialize, Default)]
446struct TomlSafetyConfig {
447    enabled: Option<bool>,
448    block_on_danger: Option<bool>,
449    large_table_threshold: Option<i64>,
450    huge_table_threshold: Option<i64>,
451    refresh_stats_mysql: Option<bool>,
452}
453
454#[derive(Deserialize, Default)]
455struct TomlAdvisorConfig {
456    run_after_migrate: Option<bool>,
457    disabled_rules: Option<Vec<String>>,
458}
459
460#[derive(Deserialize, Default)]
461struct TomlSimulationConfig {
462    simulate_before_migrate: Option<bool>,
463}
464
465/// CLI overrides that take highest priority.
466#[derive(Debug, Default, Clone)]
467pub struct CliOverrides {
468    /// Override database connection URL.
469    pub url: Option<String>,
470    /// Override the database schema for the history table.
471    pub schema: Option<String>,
472    /// Override the schema history table name.
473    pub table: Option<String>,
474    /// Override migration file locations.
475    pub locations: Option<Vec<PathBuf>>,
476    /// Override whether out-of-order migrations are allowed.
477    pub out_of_order: Option<bool>,
478    /// Override whether to validate checksums on migrate.
479    pub validate_on_migrate: Option<bool>,
480    /// Override the baseline version string.
481    pub baseline_version: Option<String>,
482    /// Override the number of connection retries.
483    pub connect_retries: Option<u32>,
484    /// Override the SSL/TLS connection mode.
485    pub ssl_mode: Option<String>,
486    /// Override the CA certificate file used to verify the server.
487    pub ssl_root_cert: Option<PathBuf>,
488    /// Override the connection timeout in seconds.
489    pub connect_timeout: Option<u32>,
490    /// Override the statement timeout in seconds.
491    pub statement_timeout: Option<u32>,
492    /// Override the logical environment name.
493    pub environment: Option<String>,
494    /// Override whether to use dependency-based migration ordering.
495    pub dependency_ordering: Option<bool>,
496    /// Override TCP keepalive interval in seconds.
497    pub keepalive: Option<u32>,
498    /// Override batch transaction mode (all-or-nothing).
499    pub batch_transaction: Option<bool>,
500}
501
502impl WaypointConfig {
503    /// Load configuration with the following priority (highest wins):
504    /// 1. CLI arguments
505    /// 2. Environment variables
506    /// 3. TOML config file
507    /// 4. Built-in defaults
508    pub fn load(config_path: Option<&str>, overrides: &CliOverrides) -> Result<Self> {
509        let mut config = WaypointConfig::default();
510
511        // Layer 3: TOML config file
512        let toml_path = config_path.unwrap_or("waypoint.toml");
513        if let Ok(content) = std::fs::read_to_string(toml_path) {
514            // Warn if config file has overly permissive permissions (Unix only)
515            #[cfg(unix)]
516            {
517                use std::os::unix::fs::PermissionsExt;
518                if let Ok(meta) = std::fs::metadata(toml_path) {
519                    let mode = meta.permissions().mode();
520                    if mode & 0o077 != 0 {
521                        log::warn!(
522                            "Config file has overly permissive permissions. Consider chmod 600.; path={}, mode={:o}",
523                            toml_path,
524                            mode
525                        );
526                    }
527                }
528            }
529            let toml_config: TomlConfig = toml::from_str(&content).map_err(|e| {
530                WaypointError::ConfigError(format!(
531                    "Failed to parse config file '{}': {}",
532                    toml_path, e
533                ))
534            })?;
535            config.apply_toml(toml_config);
536        } else if config_path.is_some() {
537            // If explicitly specified, error if not found
538            return Err(WaypointError::ConfigError(format!(
539                "Config file '{}' not found",
540                toml_path
541            )));
542        }
543
544        // Layer 2: Environment variables
545        config.apply_env();
546
547        // Layer 1: CLI overrides
548        config.apply_cli(overrides);
549
550        // Validate identifiers
551        crate::db::validate_identifier(&config.migrations.schema)?;
552        crate::db::validate_identifier(&config.migrations.table)?;
553
554        // Cap connect_retries at 20
555        if config.database.connect_retries > 20 {
556            config.database.connect_retries = 20;
557            log::warn!("connect_retries capped at 20");
558        }
559
560        Ok(config)
561    }
562
563    fn apply_toml(&mut self, toml: TomlConfig) {
564        if let Some(db) = toml.database {
565            apply_option_some!(db.url => self.database.url);
566            apply_option_some!(db.host => self.database.host);
567            apply_option_some!(db.port => self.database.port);
568            apply_option_some!(db.user => self.database.user);
569            apply_option_some!(db.password => self.database.password);
570            apply_option_some!(db.database => self.database.database);
571            apply_option!(db.connect_retries => self.database.connect_retries);
572            if let Some(v) = db.ssl_mode {
573                apply_ssl_mode(&mut self.database.ssl_mode, &v, "waypoint.toml");
574            }
575            if let Some(v) = db.ssl_root_cert {
576                self.database.ssl_root_cert = Some(PathBuf::from(v));
577            }
578            apply_option!(db.connect_timeout => self.database.connect_timeout_secs);
579            apply_option!(db.statement_timeout => self.database.statement_timeout_secs);
580            apply_option!(db.keepalive => self.database.keepalive_secs);
581            if let Some(v) = db.engine {
582                match v.parse() {
583                    Ok(kind) => self.database.engine = kind,
584                    Err(_) => log::warn!(
585                        "Invalid engine '{}' in config, using default 'postgres'. Valid values: postgres, mysql",
586                        v
587                    ),
588                }
589            }
590        }
591
592        if let Some(m) = toml.migrations {
593            if let Some(v) = m.locations {
594                self.migrations.locations = v.into_iter().map(|s| normalize_location(&s)).collect();
595            }
596            apply_option!(m.table => self.migrations.table);
597            apply_option!(m.schema => self.migrations.schema);
598            apply_option!(m.out_of_order => self.migrations.out_of_order);
599            apply_option!(m.validate_on_migrate => self.migrations.validate_on_migrate);
600            apply_option!(m.clean_enabled => self.migrations.clean_enabled);
601            apply_option!(m.baseline_version => self.migrations.baseline_version);
602            apply_option_some!(m.installed_by => self.migrations.installed_by);
603            apply_option_some!(m.environment => self.migrations.environment);
604            apply_option!(m.dependency_ordering => self.migrations.dependency_ordering);
605            apply_option!(m.show_progress => self.migrations.show_progress);
606            apply_option!(m.batch_transaction => self.migrations.batch_transaction);
607        }
608
609        if let Some(h) = toml.hooks {
610            if let Some(v) = h.before_migrate {
611                self.hooks.before_migrate = v.into_iter().map(PathBuf::from).collect();
612            }
613            if let Some(v) = h.after_migrate {
614                self.hooks.after_migrate = v.into_iter().map(PathBuf::from).collect();
615            }
616            if let Some(v) = h.before_each_migrate {
617                self.hooks.before_each_migrate = v.into_iter().map(PathBuf::from).collect();
618            }
619            if let Some(v) = h.after_each_migrate {
620                self.hooks.after_each_migrate = v.into_iter().map(PathBuf::from).collect();
621            }
622        }
623
624        if let Some(p) = toml.placeholders {
625            self.placeholders.extend(p);
626        }
627
628        if let Some(l) = toml.lint {
629            apply_option!(l.disabled_rules => self.lint.disabled_rules);
630        }
631
632        if let Some(s) = toml.snapshots {
633            if let Some(v) = s.directory {
634                self.snapshots.directory = PathBuf::from(v);
635            }
636            apply_option!(s.auto_snapshot_on_migrate => self.snapshots.auto_snapshot_on_migrate);
637            apply_option!(s.max_snapshots => self.snapshots.max_snapshots);
638            apply_option!(s.strip_definer_mysql => self.snapshots.strip_definer_mysql);
639        }
640
641        if let Some(p) = toml.preflight {
642            apply_option!(p.enabled => self.preflight.enabled);
643            apply_option!(p.max_replication_lag_mb => self.preflight.max_replication_lag_mb);
644            apply_option!(p.max_replication_lag_secs => self.preflight.max_replication_lag_secs);
645            apply_option!(p.long_query_threshold_secs => self.preflight.long_query_threshold_secs);
646        }
647
648        if let Some(g) = toml.guards {
649            apply_option!(g.enabled => self.guards.enabled);
650            if let Some(v) = g.on_require_fail {
651                match v.parse() {
652                    Ok(policy) => self.guards.on_require_fail = policy,
653                    Err(_) => log::warn!(
654                        "Invalid on_require_fail '{}' in config, using default 'error'. Valid values: error, warn, skip",
655                        v
656                    ),
657                }
658            }
659        }
660
661        if let Some(r) = toml.reversals {
662            apply_option!(r.enabled => self.reversals.enabled);
663            apply_option!(r.warn_data_loss => self.reversals.warn_data_loss);
664        }
665
666        if let Some(s) = toml.safety {
667            apply_option!(s.enabled => self.safety.enabled);
668            apply_option!(s.block_on_danger => self.safety.block_on_danger);
669            apply_option!(s.large_table_threshold => self.safety.large_table_threshold);
670            apply_option!(s.huge_table_threshold => self.safety.huge_table_threshold);
671            apply_option!(s.refresh_stats_mysql => self.safety.refresh_stats_mysql);
672        }
673
674        if let Some(a) = toml.advisor {
675            apply_option!(a.run_after_migrate => self.advisor.run_after_migrate);
676            apply_option!(a.disabled_rules => self.advisor.disabled_rules);
677        }
678
679        if let Some(s) = toml.simulation {
680            apply_option!(s.simulate_before_migrate => self.simulation.simulate_before_migrate);
681        }
682
683        if let Some(databases) = toml.databases {
684            let mut named_dbs = Vec::new();
685            for db in databases {
686                let name = db.name.unwrap_or_default();
687                // Inherit the top-level `[database]` tuning (ssl_mode,
688                // timeouts, keepalive, retries) — those are transport policy
689                // that should apply to every target — while clearing the
690                // connection *identity* fields, which each entry supplies via
691                // its own `url`. `[database]` is applied above, so `self` is
692                // already populated at this point.
693                let mut db_config = DatabaseConfig {
694                    url: None,
695                    host: None,
696                    port: None,
697                    user: None,
698                    password: None,
699                    database: None,
700                    ..self.database.clone()
701                };
702                apply_option_some!(db.url => db_config.url);
703                // Check for per-database env var
704                let env_url_key = format!("WAYPOINT_DB_{}_URL", name.to_uppercase());
705                if let Ok(url) = std::env::var(&env_url_key) {
706                    db_config.url = Some(url);
707                }
708
709                let mut mig_settings = MigrationSettings::default();
710                if let Some(m) = db.migrations {
711                    if let Some(v) = m.locations {
712                        mig_settings.locations =
713                            v.into_iter().map(|s| normalize_location(&s)).collect();
714                    }
715                    apply_option!(m.table => mig_settings.table);
716                    apply_option!(m.schema => mig_settings.schema);
717                    apply_option!(m.out_of_order => mig_settings.out_of_order);
718                    apply_option!(m.validate_on_migrate => mig_settings.validate_on_migrate);
719                    apply_option!(m.clean_enabled => mig_settings.clean_enabled);
720                    apply_option!(m.baseline_version => mig_settings.baseline_version);
721                    apply_option_some!(m.installed_by => mig_settings.installed_by);
722                    apply_option_some!(m.environment => mig_settings.environment);
723                    apply_option!(m.dependency_ordering => mig_settings.dependency_ordering);
724                    apply_option!(m.show_progress => mig_settings.show_progress);
725                    apply_option!(m.batch_transaction => mig_settings.batch_transaction);
726                }
727
728                let mut hooks_config = HooksConfig::default();
729                if let Some(h) = db.hooks {
730                    if let Some(v) = h.before_migrate {
731                        hooks_config.before_migrate = v.into_iter().map(PathBuf::from).collect();
732                    }
733                    if let Some(v) = h.after_migrate {
734                        hooks_config.after_migrate = v.into_iter().map(PathBuf::from).collect();
735                    }
736                    if let Some(v) = h.before_each_migrate {
737                        hooks_config.before_each_migrate =
738                            v.into_iter().map(PathBuf::from).collect();
739                    }
740                    if let Some(v) = h.after_each_migrate {
741                        hooks_config.after_each_migrate =
742                            v.into_iter().map(PathBuf::from).collect();
743                    }
744                }
745
746                named_dbs.push(crate::multi::NamedDatabaseConfig {
747                    name,
748                    database: db_config,
749                    migrations: mig_settings,
750                    hooks: hooks_config,
751                    placeholders: db.placeholders.unwrap_or_default(),
752                    depends_on: db.depends_on.unwrap_or_default(),
753                });
754            }
755            self.multi_database = Some(named_dbs);
756        }
757    }
758
759    fn apply_env(&mut self) {
760        if let Ok(v) = std::env::var("WAYPOINT_DATABASE_URL") {
761            self.database.url = Some(v);
762        }
763        if let Ok(v) = std::env::var("WAYPOINT_DATABASE_HOST") {
764            self.database.host = Some(v);
765        }
766        if let Ok(v) = std::env::var("WAYPOINT_DATABASE_PORT")
767            && let Ok(port) = v.parse::<u16>()
768        {
769            self.database.port = Some(port);
770        }
771        if let Ok(v) = std::env::var("WAYPOINT_DATABASE_USER") {
772            self.database.user = Some(v);
773        }
774        if let Ok(v) = std::env::var("WAYPOINT_DATABASE_PASSWORD") {
775            self.database.password = Some(v);
776        }
777        if let Ok(v) = std::env::var("WAYPOINT_DATABASE_NAME") {
778            self.database.database = Some(v);
779        }
780        if let Ok(v) = std::env::var("WAYPOINT_CONNECT_RETRIES")
781            && let Ok(n) = v.parse::<u32>()
782        {
783            self.database.connect_retries = n;
784        }
785        if let Ok(v) = std::env::var("WAYPOINT_SSL_MODE") {
786            apply_ssl_mode(&mut self.database.ssl_mode, &v, "WAYPOINT_SSL_MODE");
787        }
788        if let Ok(v) = std::env::var("WAYPOINT_SSL_ROOT_CERT") {
789            self.database.ssl_root_cert = Some(PathBuf::from(v));
790        }
791        if let Ok(v) = std::env::var("WAYPOINT_CONNECT_TIMEOUT")
792            && let Ok(n) = v.parse::<u32>()
793        {
794            self.database.connect_timeout_secs = n;
795        }
796        if let Ok(v) = std::env::var("WAYPOINT_STATEMENT_TIMEOUT")
797            && let Ok(n) = v.parse::<u32>()
798        {
799            self.database.statement_timeout_secs = n;
800        }
801        if let Ok(v) = std::env::var("WAYPOINT_MIGRATIONS_LOCATIONS") {
802            self.migrations.locations =
803                v.split(',').map(|s| normalize_location(s.trim())).collect();
804        }
805        if let Ok(v) = std::env::var("WAYPOINT_MIGRATIONS_TABLE") {
806            self.migrations.table = v;
807        }
808        if let Ok(v) = std::env::var("WAYPOINT_MIGRATIONS_SCHEMA") {
809            self.migrations.schema = v;
810        }
811
812        if let Ok(v) = std::env::var("WAYPOINT_DATABASE_ENGINE") {
813            match v.parse() {
814                Ok(kind) => self.database.engine = kind,
815                Err(_) => log::warn!(
816                    "Invalid WAYPOINT_DATABASE_ENGINE '{}', using default 'postgres'",
817                    v
818                ),
819            }
820        }
821        if let Ok(v) = std::env::var("WAYPOINT_KEEPALIVE")
822            && let Ok(n) = v.parse::<u32>()
823        {
824            self.database.keepalive_secs = n;
825        }
826        if let Ok(v) = std::env::var("WAYPOINT_BATCH_TRANSACTION") {
827            self.migrations.batch_transaction = v == "1" || v.eq_ignore_ascii_case("true");
828        }
829        if let Ok(v) = std::env::var("WAYPOINT_ENVIRONMENT") {
830            self.migrations.environment = Some(v);
831        }
832
833        // Scan for placeholder env vars: WAYPOINT_PLACEHOLDER_{KEY}
834        for (key, value) in std::env::vars() {
835            if let Some(placeholder_key) = key.strip_prefix("WAYPOINT_PLACEHOLDER_") {
836                self.placeholders
837                    .insert(placeholder_key.to_lowercase(), value);
838            }
839        }
840    }
841
842    fn apply_cli(&mut self, overrides: &CliOverrides) {
843        apply_option_some_clone!(overrides.url => self.database.url);
844        apply_option_clone!(overrides.schema => self.migrations.schema);
845        apply_option_clone!(overrides.table => self.migrations.table);
846        apply_option_clone!(overrides.locations => self.migrations.locations);
847        apply_option!(overrides.out_of_order => self.migrations.out_of_order);
848        apply_option!(overrides.validate_on_migrate => self.migrations.validate_on_migrate);
849        apply_option_clone!(overrides.baseline_version => self.migrations.baseline_version);
850        apply_option!(overrides.connect_retries => self.database.connect_retries);
851        if let Some(ref v) = overrides.ssl_mode {
852            apply_ssl_mode(&mut self.database.ssl_mode, v, "--ssl-mode");
853        }
854        apply_option_some_clone!(overrides.ssl_root_cert => self.database.ssl_root_cert);
855        apply_option!(overrides.connect_timeout => self.database.connect_timeout_secs);
856        apply_option!(overrides.statement_timeout => self.database.statement_timeout_secs);
857        apply_option_some_clone!(overrides.environment => self.migrations.environment);
858        apply_option!(overrides.dependency_ordering => self.migrations.dependency_ordering);
859        apply_option!(overrides.keepalive => self.database.keepalive_secs);
860        apply_option!(overrides.batch_transaction => self.migrations.batch_transaction);
861    }
862
863    /// Build a connection string from the config.
864    ///
865    /// Prefers `url` if set; otherwise builds from the individual `host` /
866    /// `port` / `user` / `password` / `database` fields, in the shape the
867    /// configured [`DatabaseConfig::engine`] expects:
868    ///
869    /// - PostgreSQL → libpq key=value (`host=… port=… user=… dbname=…`)
870    /// - MySQL → a `mysql://` URL, so that engine auto-detection still works
871    ///
872    /// Handles JDBC-style URLs by stripping the `jdbc:` prefix and extracting
873    /// `user` and `password` query parameters.
874    pub fn connection_string(&self) -> Result<String> {
875        if let Some(ref url) = self.database.url {
876            return Ok(normalize_jdbc_url(url));
877        }
878
879        let engine = self.database.engine;
880        let host = self.database.host.as_deref().unwrap_or("localhost");
881        let default_port = match engine {
882            crate::dialect::DialectKind::Postgres => 5432,
883            crate::dialect::DialectKind::Mysql => 3306,
884        };
885        let port = self.database.port.unwrap_or(default_port);
886        let user =
887            self.database.user.as_deref().ok_or_else(|| {
888                WaypointError::ConfigError("Database user is required".to_string())
889            })?;
890        let database =
891            self.database.database.as_deref().ok_or_else(|| {
892                WaypointError::ConfigError("Database name is required".to_string())
893            })?;
894
895        match engine {
896            crate::dialect::DialectKind::Mysql => {
897                // A URL, not key=value: `DialectKind::from_url` has to be able
898                // to route this to the MySQL backend. Credentials are
899                // percent-encoded so specials in the password stay inside the
900                // userinfo section.
901                let auth = match self.database.password {
902                    Some(ref password) => format!(
903                        "{}:{}",
904                        percent_encode_userinfo(user),
905                        percent_encode_userinfo(password)
906                    ),
907                    None => percent_encode_userinfo(user),
908                };
909                Ok(format!("mysql://{}@{}:{}/{}", auth, host, port, database))
910            }
911            crate::dialect::DialectKind::Postgres => {
912                let mut url = format!(
913                    "host={} port={} user={} dbname={}",
914                    host, port, user, database
915                );
916                if let Some(ref password) = self.database.password {
917                    // Quote password to handle special characters (spaces, quotes, etc.)
918                    let escaped = password.replace('\\', "\\\\").replace('\'', "\\'");
919                    url.push_str(&format!(" password='{}'", escaped));
920                }
921                Ok(url)
922            }
923        }
924    }
925}
926
927/// Percent-encode a URL userinfo component (username or password).
928///
929/// Everything outside the RFC 3986 unreserved set is escaped, so a password
930/// containing `@`, `:`, `/`, `?` or `#` cannot break out of the userinfo
931/// section and corrupt the authority. Hand-rolled rather than pulling in a
932/// dependency for ~15 lines.
933fn percent_encode_userinfo(s: &str) -> String {
934    let mut out = String::with_capacity(s.len());
935    for b in s.bytes() {
936        match b {
937            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
938                out.push(b as char)
939            }
940            _ => out.push_str(&format!("%{:02X}", b)),
941        }
942    }
943    out
944}
945
946/// Normalize a JDBC-style URL to a standard PostgreSQL connection string.
947///
948/// Handles:
949///   - `jdbc:postgresql://host:port/db?user=x&password=y`  →  `postgresql://x:y@host:port/db`
950///   - `postgresql://...` passed through as-is
951///   - `postgres://...` passed through as-is
952///
953/// Credentials lifted out of the query string are percent-encoded on the way
954/// into the authority; otherwise a password like `p@ss` would produce
955/// `postgres://user:p@ss@host/db`, which does not parse as intended.
956fn normalize_jdbc_url(url: &str) -> String {
957    // Strip jdbc: prefix
958    let url = url.strip_prefix("jdbc:").unwrap_or(url);
959
960    // Parse query parameters for user/password if present
961    if let Some((base, query)) = url.split_once('?') {
962        let mut user = None;
963        let mut password = None;
964        let mut other_params = Vec::new();
965
966        for param in query.split('&') {
967            if let Some((key, value)) = param.split_once('=') {
968                match key.to_lowercase().as_str() {
969                    "user" => user = Some(value.to_string()),
970                    "password" => password = Some(value.to_string()),
971                    _ => other_params.push(param.to_string()),
972                }
973            }
974        }
975
976        // If we extracted user/password, rebuild the URL with credentials in the authority
977        if (user.is_some() || password.is_some())
978            && let Some(rest) = base
979                .strip_prefix("postgresql://")
980                .or_else(|| base.strip_prefix("postgres://"))
981        {
982            let scheme = if base.starts_with("postgresql://") {
983                "postgresql"
984            } else {
985                "postgres"
986            };
987
988            let auth = match (user, password) {
989                (Some(u), Some(p)) => format!(
990                    "{}:{}@",
991                    percent_encode_userinfo(&u),
992                    percent_encode_userinfo(&p)
993                ),
994                (Some(u), None) => format!("{}@", percent_encode_userinfo(&u)),
995                (None, Some(p)) => format!(":{}@", percent_encode_userinfo(&p)),
996                (None, None) => String::new(),
997            };
998
999            let mut result = format!("{}://{}{}", scheme, auth, rest);
1000            if !other_params.is_empty() {
1001                result.push('?');
1002                result.push_str(&other_params.join("&"));
1003            }
1004            return result;
1005        }
1006
1007        // No user/password in query, return with jdbc: stripped
1008        if other_params.is_empty() {
1009            return base.to_string();
1010        }
1011        return format!("{}?{}", base, other_params.join("&"));
1012    }
1013
1014    url.to_string()
1015}
1016
1017/// Strip `filesystem:` prefix from a location path (Flyway compatibility).
1018pub fn normalize_location(location: &str) -> PathBuf {
1019    let stripped = location.strip_prefix("filesystem:").unwrap_or(location);
1020    PathBuf::from(stripped)
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025    use super::*;
1026
1027    #[test]
1028    fn test_default_config() {
1029        let config = WaypointConfig::default();
1030        assert_eq!(config.migrations.table, "waypoint_schema_history");
1031        assert_eq!(config.migrations.schema, "public");
1032        assert!(!config.migrations.out_of_order);
1033        assert!(config.migrations.validate_on_migrate);
1034        assert!(!config.migrations.clean_enabled);
1035        assert_eq!(config.migrations.baseline_version, "1");
1036        assert_eq!(
1037            config.migrations.locations,
1038            vec![PathBuf::from("db/migrations")]
1039        );
1040    }
1041
1042    #[test]
1043    fn test_connection_string_from_url() {
1044        let mut config = WaypointConfig::default();
1045        config.database.url = Some("postgres://user:pass@localhost/db".to_string());
1046        assert_eq!(
1047            config.connection_string().unwrap(),
1048            "postgres://user:pass@localhost/db"
1049        );
1050    }
1051
1052    #[test]
1053    fn test_connection_string_from_fields() {
1054        let mut config = WaypointConfig::default();
1055        config.database.host = Some("myhost".to_string());
1056        config.database.port = Some(5433);
1057        config.database.user = Some("myuser".to_string());
1058        config.database.database = Some("mydb".to_string());
1059        config.database.password = Some("secret".to_string());
1060
1061        let conn = config.connection_string().unwrap();
1062        assert!(conn.contains("host=myhost"));
1063        assert!(conn.contains("port=5433"));
1064        assert!(conn.contains("user=myuser"));
1065        assert!(conn.contains("dbname=mydb"));
1066        assert!(conn.contains("password='secret'"));
1067    }
1068
1069    #[test]
1070    fn test_connection_string_missing_user() {
1071        let mut config = WaypointConfig::default();
1072        config.database.database = Some("mydb".to_string());
1073        assert!(config.connection_string().is_err());
1074    }
1075
1076    #[test]
1077    fn test_cli_overrides() {
1078        let mut config = WaypointConfig::default();
1079        let overrides = CliOverrides {
1080            url: Some("postgres://override@localhost/db".to_string()),
1081            schema: Some("custom_schema".to_string()),
1082            table: Some("custom_table".to_string()),
1083            locations: Some(vec![PathBuf::from("custom/path")]),
1084            out_of_order: Some(true),
1085            validate_on_migrate: Some(false),
1086            baseline_version: Some("5".to_string()),
1087            connect_retries: None,
1088            ssl_mode: None,
1089            ssl_root_cert: None,
1090            connect_timeout: None,
1091            statement_timeout: None,
1092            environment: None,
1093            dependency_ordering: None,
1094            keepalive: None,
1095            batch_transaction: None,
1096        };
1097
1098        config.apply_cli(&overrides);
1099
1100        assert_eq!(
1101            config.database.url.as_deref(),
1102            Some("postgres://override@localhost/db")
1103        );
1104        assert_eq!(config.migrations.schema, "custom_schema");
1105        assert_eq!(config.migrations.table, "custom_table");
1106        assert_eq!(
1107            config.migrations.locations,
1108            vec![PathBuf::from("custom/path")]
1109        );
1110        assert!(config.migrations.out_of_order);
1111        assert!(!config.migrations.validate_on_migrate);
1112        assert_eq!(config.migrations.baseline_version, "5");
1113    }
1114
1115    #[test]
1116    fn test_toml_parsing() {
1117        let toml_str = r#"
1118[database]
1119url = "postgres://user:pass@localhost/mydb"
1120
1121[migrations]
1122table = "my_history"
1123schema = "app"
1124out_of_order = true
1125locations = ["sql/migrations", "sql/seeds"]
1126
1127[placeholders]
1128env = "production"
1129app_name = "myapp"
1130"#;
1131
1132        let toml_config: TomlConfig = toml::from_str(toml_str).unwrap();
1133        let mut config = WaypointConfig::default();
1134        config.apply_toml(toml_config);
1135
1136        assert_eq!(
1137            config.database.url.as_deref(),
1138            Some("postgres://user:pass@localhost/mydb")
1139        );
1140        assert_eq!(config.migrations.table, "my_history");
1141        assert_eq!(config.migrations.schema, "app");
1142        assert!(config.migrations.out_of_order);
1143        assert_eq!(
1144            config.migrations.locations,
1145            vec![PathBuf::from("sql/migrations"), PathBuf::from("sql/seeds")]
1146        );
1147        assert_eq!(config.placeholders.get("env").unwrap(), "production");
1148        assert_eq!(config.placeholders.get("app_name").unwrap(), "myapp");
1149    }
1150
1151    #[test]
1152    fn test_normalize_jdbc_url_with_credentials() {
1153        let url = "jdbc:postgresql://myhost:5432/mydb?user=admin&password=secret";
1154        assert_eq!(
1155            normalize_jdbc_url(url),
1156            "postgresql://admin:secret@myhost:5432/mydb"
1157        );
1158    }
1159
1160    #[test]
1161    fn test_normalize_jdbc_url_user_only() {
1162        let url = "jdbc:postgresql://myhost:5432/mydb?user=admin";
1163        assert_eq!(
1164            normalize_jdbc_url(url),
1165            "postgresql://admin@myhost:5432/mydb"
1166        );
1167    }
1168
1169    #[test]
1170    fn test_normalize_jdbc_url_strips_jdbc_prefix() {
1171        let url = "jdbc:postgresql://myhost:5432/mydb";
1172        assert_eq!(normalize_jdbc_url(url), "postgresql://myhost:5432/mydb");
1173    }
1174
1175    #[test]
1176    fn test_normalize_jdbc_url_passthrough() {
1177        let url = "postgresql://user:pass@myhost:5432/mydb";
1178        assert_eq!(normalize_jdbc_url(url), url);
1179    }
1180
1181    #[test]
1182    fn test_normalize_jdbc_url_preserves_other_params() {
1183        let url = "jdbc:postgresql://myhost:5432/mydb?user=admin&password=secret&sslmode=require";
1184        assert_eq!(
1185            normalize_jdbc_url(url),
1186            "postgresql://admin:secret@myhost:5432/mydb?sslmode=require"
1187        );
1188    }
1189
1190    #[test]
1191    fn test_normalize_location_filesystem_prefix() {
1192        assert_eq!(
1193            normalize_location("filesystem:/flyway/sql"),
1194            PathBuf::from("/flyway/sql")
1195        );
1196    }
1197
1198    #[test]
1199    fn test_normalize_location_plain_path() {
1200        assert_eq!(
1201            normalize_location("/my/migrations"),
1202            PathBuf::from("/my/migrations")
1203        );
1204    }
1205
1206    #[test]
1207    fn test_normalize_location_relative() {
1208        assert_eq!(
1209            normalize_location("filesystem:db/migrations"),
1210            PathBuf::from("db/migrations")
1211        );
1212    }
1213
1214    #[test]
1215    fn test_connection_string_password_special_chars() {
1216        let config = WaypointConfig {
1217            database: DatabaseConfig {
1218                host: Some("localhost".to_string()),
1219                port: Some(5432),
1220                user: Some("admin".to_string()),
1221                database: Some("mydb".to_string()),
1222                password: Some("p@ss'w ord".to_string()),
1223                ..Default::default()
1224            },
1225            ..Default::default()
1226        };
1227        let conn = config.connection_string().unwrap();
1228        assert!(conn.contains("password='p@ss\\'w ord'"));
1229    }
1230
1231    #[test]
1232    fn test_connection_string_mysql_from_fields() {
1233        let config = WaypointConfig {
1234            database: DatabaseConfig {
1235                engine: crate::dialect::DialectKind::Mysql,
1236                host: Some("db.internal".to_string()),
1237                user: Some("app".to_string()),
1238                password: Some("s3cr3t".to_string()),
1239                database: Some("shop".to_string()),
1240                ..Default::default()
1241            },
1242            ..Default::default()
1243        };
1244        // Defaults to the MySQL port and emits a URL the engine detector routes.
1245        assert_eq!(
1246            config.connection_string().unwrap(),
1247            "mysql://app:s3cr3t@db.internal:3306/shop"
1248        );
1249        assert_eq!(
1250            crate::dialect::DialectKind::from_url(&config.connection_string().unwrap()),
1251            Some(crate::dialect::DialectKind::Mysql)
1252        );
1253    }
1254
1255    #[test]
1256    fn test_connection_string_mysql_percent_encodes_password() {
1257        let config = WaypointConfig {
1258            database: DatabaseConfig {
1259                engine: crate::dialect::DialectKind::Mysql,
1260                host: Some("h".to_string()),
1261                port: Some(13306),
1262                user: Some("u".to_string()),
1263                password: Some("p@ss/word".to_string()),
1264                database: Some("d".to_string()),
1265                ..Default::default()
1266            },
1267            ..Default::default()
1268        };
1269        assert_eq!(
1270            config.connection_string().unwrap(),
1271            "mysql://u:p%40ss%2Fword@h:13306/d"
1272        );
1273    }
1274
1275    #[test]
1276    fn test_normalize_jdbc_url_percent_encodes_credentials() {
1277        // An unencoded `@` in the password would break the authority.
1278        let url = "jdbc:postgresql://myhost:5432/mydb?user=adm%69n&password=p@ss";
1279        assert_eq!(
1280            normalize_jdbc_url(url),
1281            "postgresql://adm%2569n:p%40ss@myhost:5432/mydb"
1282        );
1283    }
1284
1285    #[test]
1286    fn test_engine_defaults_to_postgres() {
1287        let config = WaypointConfig::default();
1288        assert_eq!(
1289            config.database.engine,
1290            crate::dialect::DialectKind::Postgres
1291        );
1292    }
1293
1294    #[test]
1295    fn test_toml_engine_key() {
1296        let toml_str = r#"
1297[database]
1298engine = "mysql"
1299host = "localhost"
1300user = "root"
1301database = "app"
1302"#;
1303        let toml_config: TomlConfig = toml::from_str(toml_str).unwrap();
1304        let mut config = WaypointConfig::default();
1305        config.apply_toml(toml_config);
1306        assert_eq!(config.database.engine, crate::dialect::DialectKind::Mysql);
1307        assert!(config.connection_string().unwrap().starts_with("mysql://"));
1308    }
1309
1310    #[test]
1311    fn test_ssl_mode_parses_the_libpq_ladder() {
1312        for (input, want) in [
1313            ("disable", SslMode::Disable),
1314            ("DISABLE", SslMode::Disable),
1315            ("disabled", SslMode::Disable),
1316            ("prefer", SslMode::Prefer),
1317            ("require", SslMode::Require),
1318            ("required", SslMode::Require),
1319            ("verify-ca", SslMode::VerifyCa),
1320            ("verify_ca", SslMode::VerifyCa),
1321            ("verifyca", SslMode::VerifyCa),
1322            ("Verify-Full", SslMode::VerifyFull),
1323            ("verify_full", SslMode::VerifyFull),
1324        ] {
1325            assert_eq!(input.parse::<SslMode>().unwrap(), want, "input: {}", input);
1326        }
1327    }
1328
1329    #[test]
1330    fn test_ssl_mode_rejects_allow_and_names_the_alternative() {
1331        // libpq's `allow` prefers plaintext; aliasing it to `prefer` would
1332        // silently invert the preference order, so it is rejected outright.
1333        let err = "allow".parse::<SslMode>().unwrap_err().to_string();
1334        assert!(err.contains("not supported"), "got: {}", err);
1335        assert!(err.contains("prefer"), "must name the replacement: {}", err);
1336    }
1337
1338    #[test]
1339    fn test_ssl_mode_rejects_unknown_values() {
1340        assert!("verify".parse::<SslMode>().is_err());
1341        assert!("".parse::<SslMode>().is_err());
1342        let err = "banana".parse::<SslMode>().unwrap_err().to_string();
1343        assert!(
1344            err.contains("verify-full"),
1345            "should list valid modes: {}",
1346            err
1347        );
1348    }
1349
1350    #[test]
1351    fn test_ssl_mode_predicates_form_a_ladder() {
1352        use SslMode::*;
1353        let ladder = [Disable, Prefer, Require, VerifyCa, VerifyFull];
1354        assert_eq!(
1355            ladder.map(|m| m.requires_tls()),
1356            [false, false, true, true, true]
1357        );
1358        assert_eq!(
1359            ladder.map(|m| m.verifies_certificate()),
1360            [false, false, false, true, true]
1361        );
1362    }
1363
1364    #[test]
1365    fn test_ssl_root_cert_defaults_to_none() {
1366        assert_eq!(WaypointConfig::default().database.ssl_root_cert, None);
1367    }
1368
1369    #[test]
1370    fn test_toml_ssl_root_cert_key() {
1371        let toml_str = r#"
1372[database]
1373url = "postgres://user@localhost/mydb"
1374ssl_mode = "verify-full"
1375ssl_root_cert = "/etc/ssl/certs/internal-ca.pem"
1376"#;
1377        let toml_config: TomlConfig = toml::from_str(toml_str).unwrap();
1378        let mut config = WaypointConfig::default();
1379        config.apply_toml(toml_config);
1380        assert_eq!(config.database.ssl_mode, SslMode::VerifyFull);
1381        assert_eq!(
1382            config.database.ssl_root_cert,
1383            Some(PathBuf::from("/etc/ssl/certs/internal-ca.pem"))
1384        );
1385    }
1386
1387    #[test]
1388    fn test_invalid_toml_ssl_mode_keeps_the_default() {
1389        let toml_str = r#"
1390[database]
1391url = "postgres://user@localhost/mydb"
1392ssl_mode = "verify-fulll"
1393"#;
1394        let toml_config: TomlConfig = toml::from_str(toml_str).unwrap();
1395        let mut config = WaypointConfig::default();
1396        config.apply_toml(toml_config);
1397        assert_eq!(config.database.ssl_mode, SslMode::Prefer);
1398    }
1399
1400    #[test]
1401    fn test_multi_database_inherits_tls_settings() {
1402        // `[[databases]]` entries carry the top-level transport policy, so a
1403        // custom CA configured once applies to every target.
1404        let toml_str = r#"
1405[database]
1406ssl_mode = "verify-full"
1407ssl_root_cert = "/etc/ssl/ca.pem"
1408
1409[[databases]]
1410name = "orders"
1411url = "postgres://user@localhost/orders"
1412"#;
1413        let toml_config: TomlConfig = toml::from_str(toml_str).unwrap();
1414        let mut config = WaypointConfig::default();
1415        config.apply_toml(toml_config);
1416        let multi = config.multi_database.expect("expected a multi-db config");
1417        let db = &multi[0];
1418        assert_eq!(db.database.ssl_mode, SslMode::VerifyFull);
1419        assert_eq!(
1420            db.database.ssl_root_cert,
1421            Some(PathBuf::from("/etc/ssl/ca.pem"))
1422        );
1423    }
1424}