Skip to main content

drizzle_cli/commands/
overrides.rs

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