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