1use std::path::PathBuf;
2
3use crate::config::{
4 Credentials, DatabaseConfig, Dialect, Driver, Extension, Filter, PostgresCreds, PostgresSslMode,
5};
6use crate::error::CliError;
7
8#[derive(clap::Args, Clone, Default)]
14pub struct ConnectionOverrides {
15 #[arg(long)]
17 pub url: Option<String>,
18
19 #[arg(long)]
21 pub host: Option<String>,
22
23 #[arg(long)]
25 pub port: Option<u16>,
26
27 #[arg(long)]
29 pub user: Option<String>,
30
31 #[arg(long)]
33 pub password: Option<String>,
34
35 #[arg(long)]
37 pub database: Option<String>,
38
39 #[arg(long)]
41 pub ssl: Option<String>,
42
43 #[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#[derive(clap::Args, Debug, Clone, Default)]
71pub struct FilterArgs {
72 #[arg(long = "tablesFilter", value_delimiter = ',')]
74 pub tables_filter: Option<Vec<String>>,
75
76 #[arg(long = "schemaFilters", alias = "schemaFilter", value_delimiter = ',')]
78 pub schema_filters: Option<Vec<String>>,
79
80 #[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
108pub 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
132pub 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
312pub 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}