Skip to main content

drizzle_cli/commands/
overrides.rs

1use std::path::PathBuf;
2
3use crate::config::{
4    Credentials, DatabaseConfig, Dialect, Driver, Extension, Filter, PostgresCreds, PostgresSslMode,
5};
6use crate::error::CliError;
7
8/// CLI overrides for database connection credentials.
9///
10/// Each field maps to a top-level `--{name}` flag. Embedded into commands
11/// that talk to a live database via `#[command(flatten)]` so the connection
12/// surface is defined once and reused across `push`, `introspect`/`pull`.
13#[derive(clap::Args, Clone, Default)]
14pub struct ConnectionOverrides {
15    /// Database connection URL
16    #[arg(long)]
17    pub url: Option<String>,
18
19    /// Database host
20    #[arg(long)]
21    pub host: Option<String>,
22
23    /// Database port
24    #[arg(long)]
25    pub port: Option<u16>,
26
27    /// Database user
28    #[arg(long)]
29    pub user: Option<String>,
30
31    /// Database password
32    #[arg(long)]
33    pub password: Option<String>,
34
35    /// Database name
36    #[arg(long)]
37    pub database: Option<String>,
38
39    /// SSL mode (true/false or require/prefer/verify-full/disable)
40    #[arg(long)]
41    pub ssl: Option<String>,
42
43    /// Turso auth token
44    #[arg(long = "authToken", alias = "auth-token")]
45    pub auth_token: Option<String>,
46}
47
48impl std::fmt::Debug for ConnectionOverrides {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.debug_struct("ConnectionOverrides")
51            .field("url", &self.url.as_ref().map(|_| "[REDACTED]"))
52            .field("host", &self.host)
53            .field("port", &self.port)
54            .field("user", &self.user)
55            .field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
56            .field("database", &self.database)
57            .field("ssl", &self.ssl)
58            .field(
59                "auth_token",
60                &self.auth_token.as_ref().map(|_| "[REDACTED]"),
61            )
62            .finish()
63    }
64}
65
66/// CLI overrides for snapshot filters (tables/schemas/extensions).
67///
68/// Embedded into commands that read or push a snapshot via
69/// `#[command(flatten)]`. Single source of truth for the filter surface.
70#[derive(clap::Args, Debug, Clone, Default)]
71pub struct FilterArgs {
72    /// Table name filters
73    #[arg(long = "tablesFilter", value_delimiter = ',')]
74    pub tables_filter: Option<Vec<String>>,
75
76    /// Schema name filters
77    #[arg(long = "schemaFilters", alias = "schemaFilter", value_delimiter = ',')]
78    pub schema_filters: Option<Vec<String>>,
79
80    /// Extension filters (e.g. postgis)
81    #[arg(long = "extensionsFilters", value_delimiter = ',', value_parser = parse_extension_arg)]
82    pub extensions_filters: Option<Vec<Extension>>,
83}
84
85fn parse_extension_arg(s: &str) -> Result<Extension, String> {
86    s.parse()
87}
88
89impl ConnectionOverrides {
90    #[must_use]
91    pub const fn has_any(&self) -> bool {
92        self.url.is_some()
93            || self.host.is_some()
94            || self.port.is_some()
95            || self.user.is_some()
96            || self.password.is_some()
97            || self.database.is_some()
98            || self.ssl.is_some()
99            || self.auth_token.is_some()
100    }
101}
102
103#[must_use]
104pub fn resolve_dialect(db: &DatabaseConfig, override_dialect: Option<Dialect>) -> Dialect {
105    override_dialect.unwrap_or(db.dialect)
106}
107
108/// Resolve the effective driver by applying CLI overrides on top of the
109/// config's value, validating that the chosen driver is compatible with the
110/// resolved dialect.
111///
112/// # Errors
113///
114/// Returns [`CliError`] if the override driver is set but is not valid for the
115/// given `dialect` (e.g. `rusqlite` selected with `postgresql`).
116pub fn resolve_driver(
117    db: &DatabaseConfig,
118    dialect: Dialect,
119    driver_override: Option<Driver>,
120) -> Result<Option<Driver>, CliError> {
121    let driver = driver_override.or(db.driver);
122    if let Some(driver) = driver
123        && !driver.is_valid_for(dialect)
124    {
125        return Err(CliError::Other(format!(
126            "driver '{driver}' invalid for {dialect} dialect"
127        )));
128    }
129    Ok(driver)
130}
131
132/// Resolve database credentials from CLI overrides, falling back to the
133/// configured value when no override is set.
134///
135/// # Errors
136///
137/// Returns [`CliError`] if an override is provided but is incompatible with
138/// the resolved dialect, or if resolving a config-provided credentials block
139/// fails (e.g. missing environment variables).
140pub fn resolve_credentials(
141    db: &DatabaseConfig,
142    dialect: Dialect,
143    overrides: &ConnectionOverrides,
144) -> Result<Option<Credentials>, CliError> {
145    if !overrides.has_any() {
146        if dialect != db.dialect {
147            return Err(CliError::Other(format!(
148                "--dialect={dialect} requires matching credential flags (--url/--host/--database/etc)"
149            )));
150        }
151        return db.credentials().map_err(Into::into);
152    }
153
154    let creds = match dialect {
155        Dialect::Sqlite => {
156            if overrides.host.is_some()
157                || overrides.port.is_some()
158                || overrides.user.is_some()
159                || overrides.password.is_some()
160                || overrides.database.is_some()
161                || overrides.ssl.is_some()
162                || overrides.auth_token.is_some()
163            {
164                return Err(CliError::Other(
165                    "sqlite credentials only support --url for local database path".into(),
166                ));
167            }
168
169            let path = overrides
170                .url
171                .clone()
172                .ok_or_else(|| CliError::Other("sqlite requires --url".into()))?;
173
174            Credentials::Sqlite {
175                path: path.into_boxed_str(),
176            }
177        }
178        Dialect::Turso => {
179            if overrides.host.is_some()
180                || overrides.port.is_some()
181                || overrides.user.is_some()
182                || overrides.password.is_some()
183                || overrides.database.is_some()
184                || overrides.ssl.is_some()
185            {
186                return Err(CliError::Other(
187                    "turso credentials support --url and optional --authToken".into(),
188                ));
189            }
190
191            let url = overrides
192                .url
193                .clone()
194                .ok_or_else(|| CliError::Other("turso requires --url".into()))?;
195
196            Credentials::Turso {
197                url: url.into_boxed_str(),
198                auth_token: overrides.auth_token.clone().map(String::into_boxed_str),
199            }
200        }
201        Dialect::Postgresql => {
202            if overrides.auth_token.is_some() {
203                return Err(CliError::Other(
204                    "postgresql does not support --authToken (use --password or --url)".into(),
205                ));
206            }
207
208            if let Some(url) = overrides.url.clone() {
209                if overrides.host.is_some()
210                    || overrides.port.is_some()
211                    || overrides.user.is_some()
212                    || overrides.password.is_some()
213                    || overrides.database.is_some()
214                    || overrides.ssl.is_some()
215                {
216                    return Err(CliError::Other(
217                        "postgresql credentials: use either --url OR --host/--database[/--port/...], not both"
218                            .into(),
219                    ));
220                }
221
222                Credentials::Postgres(PostgresCreds::Url(url.into_boxed_str()))
223            } else {
224                let host = overrides.host.clone().ok_or_else(|| {
225                    CliError::Other("postgresql host credentials require --host".into())
226                })?;
227                let database = overrides.database.clone().ok_or_else(|| {
228                    CliError::Other("postgresql host credentials require --database".into())
229                })?;
230
231                Credentials::Postgres(PostgresCreds::Host {
232                    host: host.into_boxed_str(),
233                    port: overrides.port.unwrap_or(5432),
234                    user: overrides.user.clone().map(String::into_boxed_str),
235                    password: overrides.password.clone().map(String::into_boxed_str),
236                    database: database.into_boxed_str(),
237                    ssl: parse_ssl_override(overrides.ssl.as_deref())?
238                        .unwrap_or(PostgresSslMode::Disable),
239                })
240            }
241        }
242    };
243
244    Ok(Some(creds))
245}
246
247fn parse_ssl_override(ssl: Option<&str>) -> Result<Option<PostgresSslMode>, CliError> {
248    let Some(raw) = ssl else {
249        return Ok(None);
250    };
251
252    PostgresSslMode::parse(raw).map(Some).map_err(|_| {
253        CliError::Other(format!(
254            "invalid --ssl value '{raw}'; expected one of: true,false,require,allow,prefer,verify-full,verify-ca,disable"
255        ))
256    })
257}
258
259#[must_use]
260pub fn resolve_filter_list(cli: Option<&[String]>, config: Option<&Filter>) -> Option<Vec<String>> {
261    if let Some(values) = cli {
262        if values.is_empty() {
263            return None;
264        }
265        return Some(values.to_vec());
266    }
267
268    config.map(|f| f.iter().map(ToOwned::to_owned).collect())
269}
270
271#[must_use]
272pub fn resolve_schema_filters(
273    dialect: Dialect,
274    cli: Option<&[String]>,
275    config: Option<&Filter>,
276) -> Option<Vec<String>> {
277    let resolved = resolve_filter_list(cli, config);
278    if resolved.is_some() {
279        return resolved;
280    }
281
282    if matches!(dialect, Dialect::Postgresql) {
283        Some(vec!["public".to_string()])
284    } else {
285        None
286    }
287}
288
289#[must_use]
290pub fn resolve_extensions_filter(
291    cli: Option<&[Extension]>,
292    config: Option<&[Extension]>,
293) -> Option<Vec<Extension>> {
294    if let Some(values) = cli {
295        if values.is_empty() {
296            return None;
297        }
298        return Some(values.to_vec());
299    }
300
301    config.map(<[Extension]>::to_vec)
302}
303
304#[must_use]
305pub fn resolve_schema_display(db: &DatabaseConfig, schema_override: Option<&[String]>) -> String {
306    match schema_override {
307        Some(v) if !v.is_empty() => v.join(", "),
308        _ => db.schema_display(),
309    }
310}
311
312/// Resolve the schema file paths the current command will operate on, using
313/// the CLI override if provided or the configured value otherwise.
314///
315/// # Errors
316///
317/// Returns [`CliError`] if a non-empty override resolves to zero files, if a
318/// glob pattern is invalid, or if expanding the configured schema patterns
319/// fails.
320pub fn resolve_schema_files(
321    db: &DatabaseConfig,
322    schema_override: Option<&[String]>,
323) -> Result<Vec<PathBuf>, CliError> {
324    let Some(schema_patterns) = schema_override else {
325        return db.schema_files().map_err(Into::into);
326    };
327
328    if schema_patterns.is_empty() {
329        return Err(CliError::NoSchemaFiles("(empty schema override)".into()));
330    }
331
332    let mut files = Vec::new();
333
334    for pattern in schema_patterns {
335        let pat = pattern.trim();
336        let is_glob = pat.contains('*') || pat.contains('?') || pat.contains('[');
337
338        if !is_glob {
339            let p = PathBuf::from(pat);
340            if p.exists() {
341                files.push(p);
342                continue;
343            }
344        }
345
346        let pat_norm = pat.replace('\\', "/");
347        let paths = glob::glob(&pat_norm)
348            .map_err(|e| CliError::Other(format!("invalid glob '{pat}': {e}")))?;
349        let matched: Vec<_> = paths.filter_map(Result::ok).collect();
350
351        if matched.is_empty() && !is_glob {
352            let p = PathBuf::from(&pat_norm);
353            if p.exists() {
354                files.push(p);
355            }
356        } else {
357            files.extend(matched);
358        }
359    }
360
361    files.retain(|p| p.is_file());
362    files.sort();
363    files.dedup();
364
365    if files.is_empty() {
366        return Err(CliError::NoSchemaFiles(
367            schema_patterns
368                .iter()
369                .map(std::string::String::as_str)
370                .collect::<Vec<_>>()
371                .join(", "),
372        ));
373    }
374
375    Ok(files)
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use crate::config::Config;
382    use std::path::PathBuf;
383    use tempfile::TempDir;
384
385    fn load_db(config_toml: &str) -> (TempDir, DatabaseConfig) {
386        let dir = TempDir::new().expect("temp dir");
387        let path = dir.path().join("drizzle.config.toml");
388        std::fs::write(&path, config_toml).expect("write config");
389        let config = Config::load_from(&path).expect("load config");
390        let db = config.default_database().expect("default db").clone();
391        (dir, db)
392    }
393
394    #[test]
395    fn resolve_filter_list_prefers_cli_values() {
396        let config = Filter::Many(vec!["from_config".to_string()]);
397        let cli = vec!["from_cli".to_string()];
398
399        let resolved = resolve_filter_list(Some(&cli), Some(&config));
400        assert_eq!(resolved, Some(vec!["from_cli".to_string()]));
401    }
402
403    #[test]
404    fn resolve_filter_list_uses_config_when_cli_missing() {
405        let config = Filter::Many(vec!["public".to_string(), "dev".to_string()]);
406        let resolved = resolve_filter_list(None, Some(&config));
407        assert_eq!(
408            resolved,
409            Some(vec!["public".to_string(), "dev".to_string()])
410        );
411    }
412
413    #[test]
414    fn resolve_schema_filters_defaults_to_public_for_postgres() {
415        let resolved = resolve_schema_filters(Dialect::Postgresql, None, None);
416        assert_eq!(resolved, Some(vec!["public".to_string()]));
417    }
418
419    #[test]
420    fn resolve_schema_filters_does_not_default_for_sqlite() {
421        let resolved = resolve_schema_filters(Dialect::Sqlite, None, None);
422        assert_eq!(resolved, None);
423    }
424
425    #[test]
426    fn resolve_extensions_filter_prefers_cli_values() {
427        let cli = vec![Extension::Postgis];
428        let config = vec![];
429
430        let resolved = resolve_extensions_filter(Some(&cli), Some(&config));
431        assert_eq!(resolved, Some(vec![Extension::Postgis]));
432    }
433
434    #[test]
435    fn resolve_driver_rejects_invalid_override() {
436        let (_dir, db) = load_db(
437            r#"
438dialect = "sqlite"
439schema = "src/schema.rs"
440"#,
441        );
442
443        let err = resolve_driver(&db, Dialect::Sqlite, Some(Driver::TokioPostgres))
444            .expect_err("driver should be rejected");
445        assert_eq!(
446            err.to_string(),
447            "driver 'tokio-postgres' invalid for sqlite dialect"
448        );
449    }
450
451    #[test]
452    fn resolve_credentials_requires_overrides_for_dialect_switch() {
453        let (_dir, db) = load_db(
454            r#"
455dialect = "sqlite"
456[dbCredentials]
457url = "./dev.db"
458"#,
459        );
460
461        let err = resolve_credentials(&db, Dialect::Postgresql, &ConnectionOverrides::default())
462            .expect_err("dialect switch should require explicit credentials");
463        assert_eq!(
464            err.to_string(),
465            "--dialect=postgresql requires matching credential flags (--url/--host/--database/etc)"
466        );
467    }
468
469    #[test]
470    fn resolve_credentials_sqlite_rejects_host_fields() {
471        let (_dir, db) = load_db(
472            r#"
473dialect = "sqlite"
474"#,
475        );
476
477        let overrides = ConnectionOverrides {
478            host: Some("localhost".to_string()),
479            ..Default::default()
480        };
481
482        let err = resolve_credentials(&db, Dialect::Sqlite, &overrides)
483            .expect_err("sqlite should reject host-style credentials");
484        assert_eq!(
485            err.to_string(),
486            "sqlite credentials only support --url for local database path"
487        );
488    }
489
490    #[test]
491    fn resolve_credentials_postgres_rejects_mixed_url_and_host_fields() {
492        let (_dir, db) = load_db(
493            r#"
494dialect = "postgresql"
495"#,
496        );
497
498        let overrides = ConnectionOverrides {
499            url: Some("postgres://u:p@localhost:5432/db".to_string()),
500            host: Some("localhost".to_string()),
501            database: Some("db".to_string()),
502            ..Default::default()
503        };
504
505        let err = resolve_credentials(&db, Dialect::Postgresql, &overrides)
506            .expect_err("postgres should reject mixed credentials");
507        assert_eq!(
508            err.to_string(),
509            "postgresql credentials: use either --url OR --host/--database[/--port/...], not both"
510        );
511    }
512
513    #[test]
514    fn resolve_credentials_postgres_requires_database_for_host_mode() {
515        let (_dir, db) = load_db(
516            r#"
517dialect = "postgresql"
518"#,
519        );
520
521        let overrides = ConnectionOverrides {
522            host: Some("localhost".to_string()),
523            ..Default::default()
524        };
525
526        let err = resolve_credentials(&db, Dialect::Postgresql, &overrides)
527            .expect_err("postgres host credentials require database");
528        assert_eq!(
529            err.to_string(),
530            "postgresql host credentials require --database"
531        );
532    }
533
534    #[test]
535    fn resolve_credentials_turso_accepts_url_with_optional_token() {
536        let (_dir, db) = load_db(
537            r#"
538dialect = "turso"
539"#,
540        );
541
542        let overrides = ConnectionOverrides {
543            url: Some("libsql://example.turso.io".to_string()),
544            auth_token: Some("secret".to_string()),
545            ..Default::default()
546        };
547
548        let creds = resolve_credentials(&db, Dialect::Turso, &overrides)
549            .expect("resolve creds")
550            .expect("some creds");
551
552        match creds {
553            Credentials::Turso { url, auth_token } => {
554                assert_eq!(url.as_ref(), "libsql://example.turso.io");
555                assert_eq!(auth_token.as_deref(), Some("secret"));
556            }
557            _ => panic!("expected turso credentials"),
558        }
559    }
560
561    #[test]
562    fn resolve_credentials_postgres_host_mode_accepts_ssl_modes() {
563        let (_dir, db) = load_db(
564            r#"
565dialect = "postgresql"
566"#,
567        );
568
569        let require_ssl = ConnectionOverrides {
570            host: Some("localhost".to_string()),
571            database: Some("db".to_string()),
572            ssl: Some("require".to_string()),
573            ..Default::default()
574        };
575        let creds = resolve_credentials(&db, Dialect::Postgresql, &require_ssl)
576            .expect("resolve")
577            .expect("creds");
578        match creds {
579            Credentials::Postgres(PostgresCreds::Host { ssl, .. }) => {
580                assert_eq!(ssl, PostgresSslMode::Require)
581            }
582            _ => panic!("expected postgres host creds"),
583        }
584
585        let disable_ssl = ConnectionOverrides {
586            host: Some("localhost".to_string()),
587            database: Some("db".to_string()),
588            ssl: Some("disable".to_string()),
589            ..Default::default()
590        };
591        let creds = resolve_credentials(&db, Dialect::Postgresql, &disable_ssl)
592            .expect("resolve")
593            .expect("creds");
594        match creds {
595            Credentials::Postgres(PostgresCreds::Host { ssl, .. }) => {
596                assert_eq!(ssl, PostgresSslMode::Disable)
597            }
598            _ => panic!("expected postgres host creds"),
599        }
600    }
601
602    #[test]
603    fn resolve_credentials_postgres_host_mode_rejects_invalid_ssl_value() {
604        let (_dir, db) = load_db(
605            r#"
606dialect = "postgresql"
607"#,
608        );
609
610        let overrides = ConnectionOverrides {
611            host: Some("localhost".to_string()),
612            database: Some("db".to_string()),
613            ssl: Some("maybe".to_string()),
614            ..Default::default()
615        };
616
617        let err = resolve_credentials(&db, Dialect::Postgresql, &overrides)
618            .expect_err("invalid ssl should fail");
619        assert_eq!(
620            err.to_string(),
621            "invalid --ssl value 'maybe'; expected one of: true,false,require,allow,prefer,verify-full,verify-ca,disable"
622        );
623    }
624
625    #[test]
626    fn resolve_schema_filters_defaults_to_public_in_multi_db_postgres() {
627        let dir = TempDir::new().expect("temp dir");
628        let path = dir.path().join("drizzle.config.toml");
629        std::fs::write(
630            &path,
631            r#"
632[databases.pg]
633dialect = "postgresql"
634
635[databases.pg.dbCredentials]
636url = "postgres://localhost/db"
637
638[databases.sqlite]
639dialect = "sqlite"
640
641[databases.sqlite.dbCredentials]
642url = "./dev.db"
643"#,
644        )
645        .expect("write config");
646
647        let config = Config::load_from(&path).expect("load config");
648        let db = config.database(Some("pg")).expect("pg db");
649
650        let resolved = resolve_schema_filters(Dialect::Postgresql, None, db.schema_filter.as_ref());
651        assert_eq!(resolved, Some(vec!["public".to_string()]));
652    }
653
654    #[test]
655    fn resolve_schema_files_uses_override_glob() {
656        let (dir, db) = load_db(
657            r#"
658dialect = "sqlite"
659schema = "src/schema.rs"
660"#,
661        );
662
663        let a = dir.path().join("a.schema.rs");
664        let b = dir.path().join("b.schema.rs");
665        std::fs::write(&a, "pub struct A;").expect("write a");
666        std::fs::write(&b, "pub struct B;").expect("write b");
667
668        let pattern = format!("{}/*.schema.rs", dir.path().display()).replace('\\', "/");
669        let override_patterns = vec![pattern];
670        let files = resolve_schema_files(&db, Some(&override_patterns)).expect("resolve files");
671
672        let paths: Vec<PathBuf> = files;
673        assert_eq!(paths.len(), 2);
674        assert!(paths.iter().any(|p| p.ends_with("a.schema.rs")));
675        assert!(paths.iter().any(|p| p.ends_with("b.schema.rs")));
676    }
677
678    #[test]
679    fn connection_overrides_debug_redacts_secrets() {
680        let overrides = ConnectionOverrides {
681            url: Some("postgres://alice:url-secret@localhost/app".into()),
682            password: Some("password-secret".into()),
683            auth_token: Some("token-secret".into()),
684            ..ConnectionOverrides::default()
685        };
686
687        let rendered = format!("{overrides:?}");
688        assert!(rendered.contains("[REDACTED]"));
689        assert!(!rendered.contains("url-secret"));
690        assert!(!rendered.contains("password-secret"));
691        assert!(!rendered.contains("token-secret"));
692    }
693}