Skip to main content

drizzle_cli/commands/
overrides.rs

1//! Command-line flags that override `drizzle.config.toml` for one run
2//! (connection settings and filters), and the `resolve_*` functions that
3//! merge them with the config.
4
5use std::path::PathBuf;
6
7use crate::config::{
8    Credentials, DatabaseConfig, Dialect, Driver, Extension, Filter, MySQLCreds, MySQLSslMode,
9    PostgresCreds, PostgresSslMode,
10};
11use crate::error::CliError;
12
13/// CLI overrides for database connection credentials.
14///
15/// Each field maps to a top-level `--{name}` flag. Embedded into commands
16/// that talk to a live database via `#[command(flatten)]` so the connection
17/// surface is defined once and reused across `push`, `introspect`/`pull`.
18#[derive(clap::Args, Clone, Default)]
19pub struct ConnectionOverrides {
20    /// Rust database driver
21    #[arg(long)]
22    pub driver: Option<Driver>,
23
24    /// Database connection URL
25    #[arg(long)]
26    pub url: Option<String>,
27
28    /// Database host
29    #[arg(long)]
30    pub host: Option<String>,
31
32    /// Database port
33    #[arg(long)]
34    pub port: Option<u16>,
35
36    /// Database user
37    #[arg(long)]
38    pub user: Option<String>,
39
40    /// Database password
41    #[arg(long)]
42    pub password: Option<String>,
43
44    /// Database name
45    #[arg(long)]
46    pub database: Option<String>,
47
48    /// SSL mode (true/false or require/prefer/verify-full/disable)
49    #[arg(long)]
50    pub ssl: Option<String>,
51
52    /// Turso auth token
53    #[arg(long = "authToken", alias = "auth-token")]
54    pub auth_token: Option<String>,
55}
56
57impl std::fmt::Debug for ConnectionOverrides {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        f.debug_struct("ConnectionOverrides")
60            .field("driver", &self.driver)
61            .field("url", &self.url.as_ref().map(|_| "[REDACTED]"))
62            .field("host", &self.host)
63            .field("port", &self.port)
64            .field("user", &self.user)
65            .field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
66            .field("database", &self.database)
67            .field("ssl", &self.ssl)
68            .field(
69                "auth_token",
70                &self.auth_token.as_ref().map(|_| "[REDACTED]"),
71            )
72            .finish()
73    }
74}
75
76/// CLI overrides for snapshot filters (tables/schemas/extensions).
77///
78/// Embedded into commands that read or push a snapshot via
79/// `#[command(flatten)]`. Single source of truth for the filter surface.
80#[derive(clap::Args, Debug, Clone, Default)]
81pub struct FilterArgs {
82    /// Table name filters
83    #[arg(long = "tablesFilter", value_delimiter = ',')]
84    pub tables_filter: Option<Vec<String>>,
85
86    /// Schema name filters
87    #[arg(long = "schemaFilters", alias = "schemaFilter", value_delimiter = ',')]
88    pub schema_filters: Option<Vec<String>>,
89
90    /// Extension filters (e.g. postgis)
91    #[arg(long = "extensionsFilters", value_delimiter = ',', value_parser = parse_extension_arg)]
92    pub extensions_filters: Option<Vec<Extension>>,
93}
94
95fn parse_extension_arg(s: &str) -> Result<Extension, String> {
96    s.parse()
97}
98
99impl ConnectionOverrides {
100    /// Returns `true` if any credential flag is set (`--driver` does not
101    /// count).
102    #[must_use]
103    pub const fn has_any(&self) -> bool {
104        self.url.is_some()
105            || self.host.is_some()
106            || self.port.is_some()
107            || self.user.is_some()
108            || self.password.is_some()
109            || self.database.is_some()
110            || self.ssl.is_some()
111            || self.auth_token.is_some()
112    }
113}
114
115/// A live database target: dialect, driver, and credentials, checked to fit
116/// together.
117///
118/// Keeping the driver beside the credentials means the command and the code
119/// that connects always agree on which driver to use.
120#[derive(Debug, Clone)]
121pub struct ResolvedConnection {
122    /// Effective dialect.
123    pub dialect: Dialect,
124    /// Driver to connect with.
125    pub driver: Driver,
126    /// Resolved credentials.
127    pub credentials: Credentials,
128}
129
130/// Returns `--dialect` if given, else the config's dialect.
131#[must_use]
132pub fn resolve_dialect(db: &DatabaseConfig, override_dialect: Option<Dialect>) -> Dialect {
133    override_dialect.unwrap_or(db.dialect)
134}
135
136/// Returns `driver_override`, else the config's driver (`None` if neither is
137/// set), after checking it is valid for `dialect`.
138///
139/// # Errors
140///
141/// Returns [`CliError`] if the chosen driver is not valid for `dialect` (for
142/// example `rusqlite` with `postgresql`).
143pub fn resolve_driver(
144    db: &DatabaseConfig,
145    dialect: Dialect,
146    driver_override: Option<Driver>,
147) -> Result<Option<Driver>, CliError> {
148    let driver = driver_override.or(db.driver);
149    if let Some(driver) = driver
150        && !driver.is_valid_for(dialect)
151    {
152        return Err(CliError::Other(format!(
153            "driver '{driver}' invalid for {dialect} dialect"
154        )));
155    }
156    Ok(driver)
157}
158
159/// Builds credentials from the connection flags, or returns the config's
160/// credentials when no credential flag is set.
161///
162/// Flags replace the config's `dbCredentials` entirely; they are not merged
163/// field by field.
164///
165/// # Errors
166///
167/// Returns [`CliError`] if `dialect` differs from the config's dialect but no
168/// credential flags are given, a flag does not apply to the dialect, a
169/// required flag (such as `--url` for SQLite) is missing, `--ssl` is invalid,
170/// or resolving the config's credentials fails (for example a missing
171/// environment variable).
172pub fn resolve_credentials(
173    db: &DatabaseConfig,
174    dialect: Dialect,
175    overrides: &ConnectionOverrides,
176) -> Result<Option<Credentials>, CliError> {
177    if !overrides.has_any() {
178        if dialect != db.dialect {
179            return Err(CliError::Other(format!(
180                "--dialect={dialect} requires matching credential flags (--url/--host/--database/etc)"
181            )));
182        }
183        return db.credentials().map_err(Into::into);
184    }
185
186    let creds = match dialect {
187        Dialect::Sqlite => {
188            if overrides.host.is_some()
189                || overrides.port.is_some()
190                || overrides.user.is_some()
191                || overrides.password.is_some()
192                || overrides.database.is_some()
193                || overrides.ssl.is_some()
194                || overrides.auth_token.is_some()
195            {
196                return Err(CliError::Other(
197                    "sqlite credentials only support --url for local database path".into(),
198                ));
199            }
200
201            let path = overrides
202                .url
203                .clone()
204                .ok_or_else(|| CliError::Other("sqlite requires --url".into()))?;
205
206            Credentials::Sqlite {
207                path: path.into_boxed_str(),
208            }
209        }
210        Dialect::Turso => {
211            if overrides.host.is_some()
212                || overrides.port.is_some()
213                || overrides.user.is_some()
214                || overrides.password.is_some()
215                || overrides.database.is_some()
216                || overrides.ssl.is_some()
217            {
218                return Err(CliError::Other(
219                    "turso credentials support --url and optional --authToken".into(),
220                ));
221            }
222
223            let url = overrides
224                .url
225                .clone()
226                .ok_or_else(|| CliError::Other("turso requires --url".into()))?;
227
228            Credentials::Turso {
229                url: url.into_boxed_str(),
230                auth_token: overrides.auth_token.clone().map(String::into_boxed_str),
231            }
232        }
233        Dialect::Postgresql => {
234            if overrides.auth_token.is_some() {
235                return Err(CliError::Other(
236                    "postgresql does not support --authToken (use --password or --url)".into(),
237                ));
238            }
239
240            if let Some(url) = overrides.url.clone() {
241                if overrides.host.is_some()
242                    || overrides.port.is_some()
243                    || overrides.user.is_some()
244                    || overrides.password.is_some()
245                    || overrides.database.is_some()
246                    || overrides.ssl.is_some()
247                {
248                    return Err(CliError::Other(
249                        "postgresql credentials: use either --url OR --host/--database[/--port/...], not both"
250                            .into(),
251                    ));
252                }
253
254                Credentials::Postgres(PostgresCreds::Url(url.into_boxed_str()))
255            } else {
256                let host = overrides.host.clone().ok_or_else(|| {
257                    CliError::Other("postgresql host credentials require --host".into())
258                })?;
259                let database = overrides.database.clone().ok_or_else(|| {
260                    CliError::Other("postgresql host credentials require --database".into())
261                })?;
262
263                Credentials::Postgres(PostgresCreds::Host {
264                    host: host.into_boxed_str(),
265                    port: overrides.port.unwrap_or(5432),
266                    user: overrides.user.clone().map(String::into_boxed_str),
267                    password: overrides.password.clone().map(String::into_boxed_str),
268                    database: database.into_boxed_str(),
269                    ssl: parse_ssl_override(overrides.ssl.as_deref())?
270                        .unwrap_or(PostgresSslMode::Disable),
271                })
272            }
273        }
274        Dialect::Mysql => {
275            if overrides.auth_token.is_some() {
276                return Err(CliError::Other(
277                    "mysql does not support --authToken (use --password or --url)".into(),
278                ));
279            }
280
281            if let Some(url) = overrides.url.clone() {
282                if overrides.host.is_some()
283                    || overrides.port.is_some()
284                    || overrides.user.is_some()
285                    || overrides.password.is_some()
286                    || overrides.database.is_some()
287                    || overrides.ssl.is_some()
288                {
289                    return Err(CliError::Other(
290                        "mysql credentials: use either --url OR --host/--database[/--port/...], not both"
291                            .into(),
292                    ));
293                }
294                if !url.starts_with("mysql://") {
295                    return Err(CliError::Other(
296                        "mysql --url must start with mysql://".into(),
297                    ));
298                }
299                Credentials::MySQL(MySQLCreds::Url(url.into_boxed_str()))
300            } else {
301                let host = overrides.host.clone().ok_or_else(|| {
302                    CliError::Other("mysql host credentials require --host".into())
303                })?;
304                let database = overrides.database.clone().ok_or_else(|| {
305                    CliError::Other("mysql host credentials require --database".into())
306                })?;
307
308                Credentials::MySQL(MySQLCreds::Host {
309                    host: host.into_boxed_str(),
310                    port: overrides.port.unwrap_or(3306),
311                    user: overrides.user.clone().map(String::into_boxed_str),
312                    password: overrides.password.clone().map(String::into_boxed_str),
313                    database: database.into_boxed_str(),
314                    ssl: parse_mysql_ssl_override(overrides.ssl.as_deref())?
315                        .unwrap_or(MySQLSslMode::Disable),
316                })
317            }
318        }
319    };
320
321    Ok(Some(creds))
322}
323
324/// Resolves credentials (see [`resolve_credentials`]) and picks the driver,
325/// or returns `None` when there are no credentials.
326///
327/// The driver is `--driver`, else the config's driver (when the dialect is
328/// not overridden), else a default for the credentials: `rusqlite`,
329/// `d1-http`, `libsql` for a local Turso URL or `turso` for a remote one,
330/// `aws-data-api`, `postgres-sync`, or `mysql-sync`.
331///
332/// # Errors
333///
334/// Returns [`CliError`] if resolving credentials fails or the driver does not
335/// fit the dialect or the credentials.
336pub fn resolve_connection(
337    db: &DatabaseConfig,
338    dialect: Dialect,
339    overrides: &ConnectionOverrides,
340) -> Result<Option<ResolvedConnection>, CliError> {
341    let credentials = resolve_credentials(db, dialect, overrides)?;
342    let Some(credentials) = credentials else {
343        return Ok(None);
344    };
345    let configured_driver = overrides
346        .driver
347        .or_else(|| (dialect == db.dialect).then_some(db.driver).flatten());
348    let driver = configured_driver.unwrap_or_else(|| default_driver(dialect, &credentials));
349    if !driver.is_valid_for(dialect) {
350        return Err(CliError::Other(format!(
351            "driver '{driver}' invalid for {dialect} dialect"
352        )));
353    }
354    if !driver_accepts_credentials(driver, &credentials) {
355        return Err(CliError::Other(format!(
356            "driver '{driver}' is incompatible with the resolved {dialect} credentials"
357        )));
358    }
359    Ok(Some(ResolvedConnection {
360        dialect,
361        driver,
362        credentials,
363    }))
364}
365
366fn driver_accepts_credentials(driver: Driver, credentials: &Credentials) -> bool {
367    matches!(
368        (driver, credentials),
369        (Driver::Rusqlite, Credentials::Sqlite { .. })
370            | (Driver::Libsql | Driver::Turso, Credentials::Turso { .. })
371            | (
372                Driver::PostgresSync | Driver::TokioPostgres,
373                Credentials::Postgres(_)
374            )
375            | (
376                Driver::MysqlSync | Driver::MysqlAsync,
377                Credentials::MySQL(_)
378            )
379            | (Driver::D1Http, Credentials::D1 { .. })
380            | (Driver::AwsDataApi, Credentials::AwsDataApi { .. })
381    )
382}
383
384fn default_driver(dialect: Dialect, credentials: &Credentials) -> Driver {
385    match (dialect, credentials) {
386        (Dialect::Sqlite, Credentials::D1 { .. }) => Driver::D1Http,
387        (Dialect::Postgresql, Credentials::AwsDataApi { .. }) => Driver::AwsDataApi,
388        (Dialect::Sqlite, _) => Driver::Rusqlite,
389        (Dialect::Turso, Credentials::Turso { url, .. }) => {
390            if is_local_libsql_url(url) {
391                Driver::Libsql
392            } else {
393                Driver::Turso
394            }
395        }
396        (Dialect::Postgresql, _) => Driver::PostgresSync,
397        (Dialect::Mysql, _) => Driver::MysqlSync,
398        // Config validation prevents these mismatches. Keep a deterministic
399        // fallback so this remains total for hand-built values in tests.
400        (Dialect::Turso, _) => Driver::Turso,
401    }
402}
403
404fn is_local_libsql_url(url: &str) -> bool {
405    !url.starts_with("libsql://") && !url.starts_with("http://") && !url.starts_with("https://")
406}
407
408fn parse_ssl_override(ssl: Option<&str>) -> Result<Option<PostgresSslMode>, CliError> {
409    let Some(raw) = ssl else {
410        return Ok(None);
411    };
412
413    PostgresSslMode::parse(raw).map(Some).map_err(|_| {
414        CliError::Other(format!(
415            "invalid --ssl value '{raw}'; expected one of: true,false,require,allow,prefer,verify-full,verify-ca,disable"
416        ))
417    })
418}
419
420fn parse_mysql_ssl_override(ssl: Option<&str>) -> Result<Option<MySQLSslMode>, CliError> {
421    let Some(raw) = ssl else {
422        return Ok(None);
423    };
424
425    MySQLSslMode::parse(raw).map(Some).map_err(|_| {
426        CliError::Other(format!(
427            "invalid --ssl value '{raw}' for mysql; expected one of: true,false,required,verify-ca,verify-identity,disable"
428        ))
429    })
430}
431
432/// Returns the `--tablesFilter`-style flag values if given (an empty flag
433/// clears the filter), else the config's patterns.
434#[must_use]
435pub fn resolve_filter_list(cli: Option<&[String]>, config: Option<&Filter>) -> Option<Vec<String>> {
436    if let Some(values) = cli {
437        if values.is_empty() {
438            return None;
439        }
440        return Some(values.to_vec());
441    }
442
443    config.map(|f| f.iter().map(ToOwned::to_owned).collect())
444}
445
446/// Like [`resolve_filter_list`] for `schemaFilter`, but PostgreSQL only
447/// (`None` otherwise) and defaulting to `["public"]` when neither the flag nor
448/// the config sets it.
449#[must_use]
450pub fn resolve_schema_filters(
451    dialect: Dialect,
452    cli: Option<&[String]>,
453    config: Option<&Filter>,
454) -> Option<Vec<String>> {
455    if dialect != Dialect::Postgresql {
456        return None;
457    }
458    let resolved = resolve_filter_list(cli, config);
459    if resolved.is_some() {
460        return resolved;
461    }
462
463    if matches!(dialect, Dialect::Postgresql) {
464        Some(vec!["public".to_string()])
465    } else {
466        None
467    }
468}
469
470/// Returns the `--extensionsFilters` values if given (an empty flag clears
471/// the filter), else the config's list; always `None` outside PostgreSQL.
472#[must_use]
473pub fn resolve_extensions_filter(
474    dialect: Dialect,
475    cli: Option<&[Extension]>,
476    config: Option<&[Extension]>,
477) -> Option<Vec<Extension>> {
478    if dialect != Dialect::Postgresql {
479        return None;
480    }
481    if let Some(values) = cli {
482        if values.is_empty() {
483            return None;
484        }
485        return Some(values.to_vec());
486    }
487
488    config.map(<[Extension]>::to_vec)
489}
490
491/// Returns the `--schema` override joined for display, else the config's
492/// schema paths.
493#[must_use]
494pub fn resolve_schema_display(db: &DatabaseConfig, schema_override: Option<&[String]>) -> String {
495    match schema_override {
496        Some(v) if !v.is_empty() => v.join(", "),
497        _ => db.schema_display(),
498    }
499}
500
501/// Resolves the schema files a command reads: the `--schema` override (paths
502/// relative to the current directory) if given, else the config's `schema`
503/// (relative to the config file).
504///
505/// # Errors
506///
507/// Returns [`CliError`] if a non-empty override resolves to zero files, if a
508/// glob pattern is invalid, or if expanding the configured schema patterns
509/// fails.
510pub fn resolve_schema_files(
511    db: &DatabaseConfig,
512    schema_override: Option<&[String]>,
513) -> Result<Vec<PathBuf>, CliError> {
514    let Some(schema_patterns) = schema_override else {
515        return db.schema_files().map_err(Into::into);
516    };
517
518    if schema_patterns.is_empty() {
519        return Err(CliError::NoSchemaFiles("(empty schema override)".into()));
520    }
521
522    let files = crate::config::resolve_schema_patterns(schema_patterns.iter().map(String::as_str))?;
523
524    if files.is_empty() {
525        return Err(CliError::NoSchemaFiles(
526            schema_patterns
527                .iter()
528                .map(std::string::String::as_str)
529                .collect::<Vec<_>>()
530                .join(", "),
531        ));
532    }
533
534    Ok(files)
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use crate::config::Config;
541    use std::path::PathBuf;
542    use tempfile::TempDir;
543
544    fn load_db(config_toml: &str) -> (TempDir, DatabaseConfig) {
545        let dir = TempDir::new().expect("temp dir");
546        let path = dir.path().join("drizzle.config.toml");
547        std::fs::write(&path, config_toml).expect("write config");
548        let config = Config::load_from(&path).expect("load config");
549        let db = config.default_database().expect("default db").clone();
550        (dir, db)
551    }
552
553    #[test]
554    fn resolve_filter_list_prefers_cli_values() {
555        let config = Filter::Many(vec!["from_config".to_string()]);
556        let cli = vec!["from_cli".to_string()];
557
558        let resolved = resolve_filter_list(Some(&cli), Some(&config));
559        assert_eq!(resolved, Some(vec!["from_cli".to_string()]));
560    }
561
562    #[test]
563    fn resolve_filter_list_uses_config_when_cli_missing() {
564        let config = Filter::Many(vec!["public".to_string(), "dev".to_string()]);
565        let resolved = resolve_filter_list(None, Some(&config));
566        assert_eq!(
567            resolved,
568            Some(vec!["public".to_string(), "dev".to_string()])
569        );
570    }
571
572    #[test]
573    fn resolve_schema_filters_defaults_to_public_for_postgres() {
574        let resolved = resolve_schema_filters(Dialect::Postgresql, None, None);
575        assert_eq!(resolved, Some(vec!["public".to_string()]));
576    }
577
578    #[test]
579    fn resolve_schema_filters_does_not_default_for_sqlite() {
580        let resolved = resolve_schema_filters(Dialect::Sqlite, None, None);
581        assert_eq!(resolved, None);
582    }
583
584    #[test]
585    fn non_postgres_filters_are_ignored_after_cli_warning() {
586        let schemas = vec!["public".to_string()];
587        let extensions = vec![Extension::Postgis];
588        for dialect in [Dialect::Sqlite, Dialect::Turso, Dialect::Mysql] {
589            assert_eq!(resolve_schema_filters(dialect, Some(&schemas), None), None);
590            assert_eq!(
591                resolve_extensions_filter(dialect, Some(&extensions), None),
592                None
593            );
594        }
595    }
596
597    #[test]
598    fn resolve_extensions_filter_prefers_cli_values() {
599        let cli = vec![Extension::Postgis];
600        let config = vec![];
601
602        let resolved = resolve_extensions_filter(Dialect::Postgresql, Some(&cli), Some(&config));
603        assert_eq!(resolved, Some(vec![Extension::Postgis]));
604    }
605
606    #[test]
607    fn resolve_driver_rejects_invalid_override() {
608        let (_dir, db) = load_db(
609            r#"
610dialect = "sqlite"
611schema = "src/schema.rs"
612"#,
613        );
614
615        let err = resolve_driver(&db, Dialect::Sqlite, Some(Driver::TokioPostgres))
616            .expect_err("driver should be rejected");
617        assert_eq!(
618            err.to_string(),
619            "driver 'tokio-postgres' invalid for sqlite dialect"
620        );
621    }
622
623    #[test]
624    fn resolve_credentials_requires_overrides_for_dialect_switch() {
625        let (_dir, db) = load_db(
626            r#"
627dialect = "sqlite"
628[dbCredentials]
629url = "./dev.db"
630"#,
631        );
632
633        let err = resolve_credentials(&db, Dialect::Postgresql, &ConnectionOverrides::default())
634            .expect_err("dialect switch should require explicit credentials");
635        assert_eq!(
636            err.to_string(),
637            "--dialect=postgresql requires matching credential flags (--url/--host/--database/etc)"
638        );
639    }
640
641    #[test]
642    fn resolve_credentials_sqlite_rejects_host_fields() {
643        let (_dir, db) = load_db(
644            r#"
645dialect = "sqlite"
646"#,
647        );
648
649        let overrides = ConnectionOverrides {
650            host: Some("localhost".to_string()),
651            ..Default::default()
652        };
653
654        let err = resolve_credentials(&db, Dialect::Sqlite, &overrides)
655            .expect_err("sqlite should reject host-style credentials");
656        assert_eq!(
657            err.to_string(),
658            "sqlite credentials only support --url for local database path"
659        );
660    }
661
662    #[test]
663    fn resolve_credentials_postgres_rejects_mixed_url_and_host_fields() {
664        let (_dir, db) = load_db(
665            r#"
666dialect = "postgresql"
667"#,
668        );
669
670        let overrides = ConnectionOverrides {
671            url: Some("postgres://u:p@localhost:5432/db".to_string()),
672            host: Some("localhost".to_string()),
673            database: Some("db".to_string()),
674            ..Default::default()
675        };
676
677        let err = resolve_credentials(&db, Dialect::Postgresql, &overrides)
678            .expect_err("postgres should reject mixed credentials");
679        assert_eq!(
680            err.to_string(),
681            "postgresql credentials: use either --url OR --host/--database[/--port/...], not both"
682        );
683    }
684
685    #[test]
686    fn resolve_credentials_postgres_requires_database_for_host_mode() {
687        let (_dir, db) = load_db(
688            r#"
689dialect = "postgresql"
690"#,
691        );
692
693        let overrides = ConnectionOverrides {
694            host: Some("localhost".to_string()),
695            ..Default::default()
696        };
697
698        let err = resolve_credentials(&db, Dialect::Postgresql, &overrides)
699            .expect_err("postgres host credentials require database");
700        assert_eq!(
701            err.to_string(),
702            "postgresql host credentials require --database"
703        );
704    }
705
706    #[test]
707    fn resolve_credentials_turso_accepts_url_with_optional_token() {
708        let (_dir, db) = load_db(
709            r#"
710dialect = "turso"
711"#,
712        );
713
714        let overrides = ConnectionOverrides {
715            url: Some("libsql://example.turso.io".to_string()),
716            auth_token: Some("secret".to_string()),
717            ..Default::default()
718        };
719
720        let creds = resolve_credentials(&db, Dialect::Turso, &overrides)
721            .expect("resolve creds")
722            .expect("some creds");
723
724        match creds {
725            Credentials::Turso { url, auth_token } => {
726                assert_eq!(url.as_ref(), "libsql://example.turso.io");
727                assert_eq!(auth_token.as_deref(), Some("secret"));
728            }
729            _ => panic!("expected turso credentials"),
730        }
731    }
732
733    #[test]
734    fn resolve_credentials_postgres_host_mode_accepts_ssl_modes() {
735        let (_dir, db) = load_db(
736            r#"
737dialect = "postgresql"
738"#,
739        );
740
741        let require_ssl = ConnectionOverrides {
742            host: Some("localhost".to_string()),
743            database: Some("db".to_string()),
744            ssl: Some("require".to_string()),
745            ..Default::default()
746        };
747        let creds = resolve_credentials(&db, Dialect::Postgresql, &require_ssl)
748            .expect("resolve")
749            .expect("creds");
750        match creds {
751            Credentials::Postgres(PostgresCreds::Host { ssl, .. }) => {
752                assert_eq!(ssl, PostgresSslMode::Require)
753            }
754            _ => panic!("expected postgres host creds"),
755        }
756
757        let disable_ssl = ConnectionOverrides {
758            host: Some("localhost".to_string()),
759            database: Some("db".to_string()),
760            ssl: Some("disable".to_string()),
761            ..Default::default()
762        };
763        let creds = resolve_credentials(&db, Dialect::Postgresql, &disable_ssl)
764            .expect("resolve")
765            .expect("creds");
766        match creds {
767            Credentials::Postgres(PostgresCreds::Host { ssl, .. }) => {
768                assert_eq!(ssl, PostgresSslMode::Disable)
769            }
770            _ => panic!("expected postgres host creds"),
771        }
772    }
773
774    #[test]
775    fn resolve_credentials_postgres_host_mode_rejects_invalid_ssl_value() {
776        let (_dir, db) = load_db(
777            r#"
778dialect = "postgresql"
779"#,
780        );
781
782        let overrides = ConnectionOverrides {
783            host: Some("localhost".to_string()),
784            database: Some("db".to_string()),
785            ssl: Some("maybe".to_string()),
786            ..Default::default()
787        };
788
789        let err = resolve_credentials(&db, Dialect::Postgresql, &overrides)
790            .expect_err("invalid ssl should fail");
791        assert_eq!(
792            err.to_string(),
793            "invalid --ssl value 'maybe'; expected one of: true,false,require,allow,prefer,verify-full,verify-ca,disable"
794        );
795    }
796
797    #[test]
798    fn resolve_connection_preserves_explicit_mysql_async_driver() {
799        let (_dir, db) = load_db(
800            r#"
801dialect = "mysql"
802driver = "mysql-sync"
803"#,
804        );
805        let overrides = ConnectionOverrides {
806            driver: Some(Driver::MysqlAsync),
807            host: Some("localhost".to_string()),
808            database: Some("app".to_string()),
809            ssl: Some("verify-ca".to_string()),
810            ..Default::default()
811        };
812        let target = resolve_connection(&db, Dialect::Mysql, &overrides)
813            .expect("resolve")
814            .expect("target");
815        assert_eq!(target.driver, Driver::MysqlAsync);
816        assert_eq!(target.dialect, Dialect::Mysql);
817        match target.credentials {
818            Credentials::MySQL(MySQLCreds::Host { port, ssl, .. }) => {
819                assert_eq!(port, 3306);
820                assert_eq!(ssl, MySQLSslMode::VerifyCa);
821            }
822            other => panic!("unexpected credentials: {other:?}"),
823        }
824    }
825
826    #[test]
827    fn resolve_connection_defaults_mysql_to_sync() {
828        let (_dir, db) = load_db(
829            r#"
830dialect = "mysql"
831[dbCredentials]
832url = "mysql://localhost/app"
833"#,
834        );
835        let target = resolve_connection(&db, Dialect::Mysql, &ConnectionOverrides::default())
836            .expect("resolve")
837            .expect("target");
838        assert_eq!(target.driver, Driver::MysqlSync);
839    }
840
841    #[test]
842    fn resolve_connection_rejects_driver_with_wrong_credential_shape() {
843        let (_dir, db) = load_db(
844            r#"
845dialect = "postgresql"
846[dbCredentials]
847url = "postgres://localhost/app"
848"#,
849        );
850        let overrides = ConnectionOverrides {
851            driver: Some(Driver::AwsDataApi),
852            ..Default::default()
853        };
854
855        let error = resolve_connection(&db, Dialect::Postgresql, &overrides)
856            .expect_err("AWS adapter cannot consume TCP credentials");
857        assert!(error.to_string().contains("incompatible"));
858    }
859
860    #[test]
861    fn dialect_switch_does_not_reuse_original_driver() {
862        let (_dir, db) = load_db(
863            r#"
864dialect = "sqlite"
865driver = "rusqlite"
866"#,
867        );
868        let overrides = ConnectionOverrides {
869            url: Some("mysql://localhost/app".to_string()),
870            ..Default::default()
871        };
872        let target = resolve_connection(&db, Dialect::Mysql, &overrides)
873            .expect("resolve switch")
874            .expect("target");
875        assert_eq!(target.driver, Driver::MysqlSync);
876    }
877
878    #[test]
879    fn mysql_override_rejects_postgres_url_and_auth_token() {
880        let (_dir, db) = load_db("dialect = \"mysql\"");
881        let bad_url = ConnectionOverrides {
882            url: Some("postgres://localhost/app".to_string()),
883            ..Default::default()
884        };
885        assert!(
886            resolve_credentials(&db, Dialect::Mysql, &bad_url)
887                .expect_err("wrong scheme")
888                .to_string()
889                .contains("mysql://")
890        );
891
892        let auth_token = ConnectionOverrides {
893            host: Some("localhost".to_string()),
894            database: Some("app".to_string()),
895            auth_token: Some("unsupported".to_string()),
896            ..Default::default()
897        };
898        assert!(
899            resolve_credentials(&db, Dialect::Mysql, &auth_token)
900                .expect_err("auth token")
901                .to_string()
902                .contains("does not support --authToken")
903        );
904    }
905
906    #[test]
907    fn resolve_schema_filters_defaults_to_public_in_multi_db_postgres() {
908        let dir = TempDir::new().expect("temp dir");
909        let path = dir.path().join("drizzle.config.toml");
910        std::fs::write(
911            &path,
912            r#"
913[databases.pg]
914dialect = "postgresql"
915
916[databases.pg.dbCredentials]
917url = "postgres://localhost/db"
918
919[databases.sqlite]
920dialect = "sqlite"
921
922[databases.sqlite.dbCredentials]
923url = "./dev.db"
924"#,
925        )
926        .expect("write config");
927
928        let config = Config::load_from(&path).expect("load config");
929        let db = config.database(Some("pg")).expect("pg db");
930
931        let resolved = resolve_schema_filters(Dialect::Postgresql, None, db.schema_filter.as_ref());
932        assert_eq!(resolved, Some(vec!["public".to_string()]));
933    }
934
935    #[test]
936    fn resolve_schema_files_uses_override_glob() {
937        let (dir, db) = load_db(
938            r#"
939dialect = "sqlite"
940schema = "src/schema.rs"
941"#,
942        );
943
944        let a = dir.path().join("a.schema.rs");
945        let b = dir.path().join("b.schema.rs");
946        std::fs::write(&a, "pub struct A;").expect("write a");
947        std::fs::write(&b, "pub struct B;").expect("write b");
948
949        let pattern = format!("{}/*.schema.rs", dir.path().display()).replace('\\', "/");
950        let override_patterns = vec![pattern];
951        let files = resolve_schema_files(&db, Some(&override_patterns)).expect("resolve files");
952
953        let paths: Vec<PathBuf> = files;
954        assert_eq!(paths.len(), 2);
955        assert!(paths.iter().any(|p| p.ends_with("a.schema.rs")));
956        assert!(paths.iter().any(|p| p.ends_with("b.schema.rs")));
957    }
958
959    #[test]
960    fn connection_overrides_debug_redacts_secrets() {
961        let overrides = ConnectionOverrides {
962            url: Some("postgres://alice:url-secret@localhost/app".into()),
963            password: Some("password-secret".into()),
964            auth_token: Some("token-secret".into()),
965            ..ConnectionOverrides::default()
966        };
967
968        let rendered = format!("{overrides:?}");
969        assert!(rendered.contains("[REDACTED]"));
970        assert!(!rendered.contains("url-secret"));
971        assert!(!rendered.contains("password-secret"));
972        assert!(!rendered.contains("token-secret"));
973    }
974}