1pub use drizzle_types::{Casing, ConfigValue, ConfigValueError};
10use schemars::JsonSchema;
11use serde::Deserialize;
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15pub const CONFIG_FILE: &str = "drizzle.config.toml";
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Deserialize, JsonSchema)]
19pub enum IntrospectCasing {
20 #[default]
22 #[serde(rename = "camel")]
23 Camel,
24 #[serde(rename = "preserve")]
26 Preserve,
27}
28
29impl IntrospectCasing {
30 #[must_use]
31 pub const fn as_str(self) -> &'static str {
32 match self {
33 Self::Camel => "camel",
34 Self::Preserve => "preserve",
35 }
36 }
37}
38
39impl std::fmt::Display for IntrospectCasing {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 f.write_str(self.as_str())
42 }
43}
44
45impl std::str::FromStr for IntrospectCasing {
46 type Err = String;
47
48 fn from_str(s: &str) -> Result<Self, Self::Err> {
49 match s {
50 "camel" | "camelCase" => Ok(Self::Camel),
51 "preserve" => Ok(Self::Preserve),
52 _ => Err(format!(
53 "invalid introspect casing '{s}', expected 'camel' or 'preserve'"
54 )),
55 }
56 }
57}
58
59#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
61pub struct IntrospectConfig {
62 #[serde(default)]
64 pub casing: IntrospectCasing,
65}
66
67#[derive(Debug, Clone, Deserialize, JsonSchema)]
76#[serde(untagged)]
77pub enum RolesFilter {
78 Bool(bool),
80 Config {
82 #[serde(default)]
84 provider: Option<String>,
85 #[serde(default)]
87 include: Option<Vec<String>>,
88 #[serde(default)]
90 exclude: Option<Vec<String>>,
91 },
92}
93
94impl Default for RolesFilter {
95 fn default() -> Self {
96 Self::Bool(false)
97 }
98}
99
100impl RolesFilter {
101 #[must_use]
103 pub const fn is_enabled(&self) -> bool {
104 match self {
105 Self::Bool(b) => *b,
106 Self::Config { .. } => true,
107 }
108 }
109
110 #[must_use]
112 pub fn should_include(&self, role_name: &str) -> bool {
113 match self {
114 Self::Bool(b) => *b,
115 Self::Config {
116 provider,
117 include,
118 exclude,
119 } => {
120 if let Some(p) = provider
122 && is_provider_role(p, role_name)
123 {
124 return false;
125 }
126 if let Some(excl) = exclude
128 && excl.iter().any(|e| e == role_name)
129 {
130 return false;
131 }
132 if let Some(incl) = include {
134 return incl.iter().any(|i| i == role_name);
135 }
136 true
137 }
138 }
139 }
140}
141
142fn is_provider_role(provider: &str, role_name: &str) -> bool {
144 match provider {
145 "supabase" => matches!(
146 role_name,
147 "anon"
148 | "authenticated"
149 | "service_role"
150 | "supabase_admin"
151 | "supabase_auth_admin"
152 | "supabase_storage_admin"
153 | "dashboard_user"
154 | "supabase_replication_admin"
155 | "supabase_read_only_user"
156 | "supabase_realtime_admin"
157 | "supabase_functions_admin"
158 | "postgres"
159 | "pgbouncer"
160 | "pgsodium_keyholder"
161 | "pgsodium_keyiduser"
162 | "pgsodium_keymaker"
163 ),
164 "neon" => matches!(
165 role_name,
166 "neon_superuser" | "cloud_admin" | "authenticated" | "anonymous"
167 ),
168 _ => false,
169 }
170}
171
172#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
176pub struct EntitiesFilter {
177 #[serde(default)]
179 pub roles: RolesFilter,
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, JsonSchema)]
188#[serde(rename_all = "lowercase")]
189pub enum Extension {
190 Postgis,
192}
193
194impl Extension {
195 #[must_use]
196 pub const fn as_str(self) -> &'static str {
197 match self {
198 Self::Postgis => "postgis",
199 }
200 }
201}
202
203impl std::fmt::Display for Extension {
204 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205 f.write_str(self.as_str())
206 }
207}
208
209#[derive(
215 Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, Deserialize, JsonSchema,
216)]
217#[serde(rename_all = "lowercase")]
218pub enum Dialect {
219 #[default]
220 Sqlite,
221 #[serde(alias = "postgres")]
222 Postgresql,
223 Mysql,
224 Turso,
225}
226
227impl Dialect {
228 pub const ALL: &'static [&'static str] = &["sqlite", "postgresql", "mysql", "turso"];
229
230 #[inline]
231 #[must_use]
232 pub const fn as_str(self) -> &'static str {
233 match self {
234 Self::Sqlite => "sqlite",
235 Self::Postgresql => "postgresql",
236 Self::Mysql => "mysql",
237 Self::Turso => "turso",
238 }
239 }
240
241 #[inline]
242 #[must_use]
243 pub const fn to_base(self) -> drizzle_types::Dialect {
244 match self {
245 Self::Sqlite | Self::Turso => drizzle_types::Dialect::SQLite,
246 Self::Postgresql => drizzle_types::Dialect::PostgreSQL,
247 Self::Mysql => drizzle_types::Dialect::MySQL,
248 }
249 }
250}
251
252impl std::fmt::Display for Dialect {
253 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254 f.write_str(self.as_str())
255 }
256}
257
258impl std::str::FromStr for Dialect {
259 type Err = String;
260
261 fn from_str(s: &str) -> Result<Self, Self::Err> {
262 match s {
263 "sqlite" => Ok(Self::Sqlite),
264 "postgresql" | "postgres" => Ok(Self::Postgresql),
265 "mysql" => Ok(Self::Mysql),
266 "turso" => Ok(Self::Turso),
267 _ => Err(format!(
268 "invalid dialect '{}', expected one of: {}",
269 s,
270 Self::ALL.join(", ")
271 )),
272 }
273 }
274}
275
276impl From<Dialect> for drizzle_types::Dialect {
277 #[inline]
278 fn from(d: Dialect) -> Self {
279 d.to_base()
280 }
281}
282
283#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, JsonSchema)]
289#[serde(rename_all = "kebab-case")]
290pub enum Driver {
291 Rusqlite,
293 Libsql,
295 Turso,
297 PostgresSync,
299 TokioPostgres,
301 MysqlSync,
303 MysqlAsync,
305 D1Http,
313 DurableSqlite,
321 AwsDataApi,
335}
336
337impl Driver {
338 pub const ALL: &'static [&'static str] = &[
339 "rusqlite",
340 "libsql",
341 "turso",
342 "postgres-sync",
343 "tokio-postgres",
344 "mysql-sync",
345 "mysql-async",
346 "d1-http",
347 "durable-sqlite",
348 "aws-data-api",
349 ];
350
351 #[inline]
352 #[must_use]
353 pub const fn as_str(self) -> &'static str {
354 match self {
355 Self::Rusqlite => "rusqlite",
356 Self::Libsql => "libsql",
357 Self::Turso => "turso",
358 Self::PostgresSync => "postgres-sync",
359 Self::TokioPostgres => "tokio-postgres",
360 Self::MysqlSync => "mysql-sync",
361 Self::MysqlAsync => "mysql-async",
362 Self::D1Http => "d1-http",
363 Self::DurableSqlite => "durable-sqlite",
364 Self::AwsDataApi => "aws-data-api",
365 }
366 }
367
368 #[must_use]
369 pub const fn valid_for(dialect: Dialect) -> &'static [Self] {
370 match dialect {
371 Dialect::Sqlite => &[Self::Rusqlite, Self::D1Http, Self::DurableSqlite],
375 Dialect::Turso => &[Self::Libsql, Self::Turso],
376 Dialect::Postgresql => &[Self::PostgresSync, Self::TokioPostgres, Self::AwsDataApi],
377 Dialect::Mysql => &[Self::MysqlSync, Self::MysqlAsync],
378 }
379 }
380
381 #[inline]
382 #[must_use]
383 pub const fn is_valid_for(self, dialect: Dialect) -> bool {
384 matches!(
385 (self, dialect),
386 (
387 Self::Rusqlite | Self::D1Http | Self::DurableSqlite,
388 Dialect::Sqlite
389 ) | (Self::Libsql | Self::Turso, Dialect::Turso)
390 | (
391 Self::PostgresSync | Self::TokioPostgres | Self::AwsDataApi,
392 Dialect::Postgresql
393 )
394 | (Self::MysqlSync | Self::MysqlAsync, Dialect::Mysql)
395 )
396 }
397
398 #[inline]
402 #[must_use]
403 pub const fn is_codegen_only(self) -> bool {
404 matches!(self, Self::DurableSqlite)
405 }
406}
407
408impl std::fmt::Display for Driver {
409 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
410 f.write_str(self.as_str())
411 }
412}
413
414impl std::str::FromStr for Driver {
415 type Err = String;
416
417 fn from_str(s: &str) -> Result<Self, Self::Err> {
418 match s {
419 "rusqlite" => Ok(Self::Rusqlite),
420 "libsql" => Ok(Self::Libsql),
421 "turso" => Ok(Self::Turso),
422 "postgres-sync" => Ok(Self::PostgresSync),
423 "tokio-postgres" => Ok(Self::TokioPostgres),
424 "mysql-sync" => Ok(Self::MysqlSync),
425 "mysql-async" => Ok(Self::MysqlAsync),
426 "d1-http" => Ok(Self::D1Http),
427 "durable-sqlite" => Ok(Self::DurableSqlite),
428 "aws-data-api" => Ok(Self::AwsDataApi),
429 _ => Err(format!(
430 "invalid driver '{}', expected one of: {}",
431 s,
432 Self::ALL.join(", ")
433 )),
434 }
435 }
436}
437
438impl std::str::FromStr for Extension {
439 type Err = String;
440
441 fn from_str(s: &str) -> Result<Self, Self::Err> {
442 match s {
443 "postgis" => Ok(Self::Postgis),
444 _ => Err(format!(
445 "invalid extension filter '{s}', expected 'postgis'"
446 )),
447 }
448 }
449}
450
451#[derive(Clone)]
457pub enum Credentials {
458 Sqlite { path: Box<str> },
460
461 Turso {
463 url: Box<str>,
464 auth_token: Option<Box<str>>,
465 },
466
467 Postgres(PostgresCreds),
469
470 MySQL(MySQLCreds),
472
473 D1 {
479 account_id: Box<str>,
480 database_id: Box<str>,
481 token: Box<str>,
482 },
483
484 AwsDataApi {
490 database: Box<str>,
491 secret_arn: Box<str>,
492 resource_arn: Box<str>,
493 },
494}
495
496#[derive(Clone)]
498pub enum PostgresCreds {
499 Url(Box<str>),
500 Host {
501 host: Box<str>,
502 port: u16,
503 user: Option<Box<str>>,
504 password: Option<Box<str>>,
505 database: Box<str>,
506 ssl: PostgresSslMode,
507 },
508}
509
510#[derive(Clone)]
513pub enum MySQLCreds {
514 Url(Box<str>),
515 Host {
516 host: Box<str>,
517 port: u16,
518 user: Option<Box<str>>,
519 password: Option<Box<str>>,
520 database: Box<str>,
521 ssl: MySQLSslMode,
522 },
523}
524
525impl std::fmt::Debug for Credentials {
526 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
527 match self {
528 Self::Sqlite { path } => f.debug_struct("Sqlite").field("path", path).finish(),
529 Self::Turso { url, auth_token } => f
530 .debug_struct("Turso")
531 .field("url", url)
532 .field("auth_token", &auth_token.as_ref().map(|_| "[REDACTED]"))
533 .finish(),
534 Self::Postgres(credentials) => f.debug_tuple("Postgres").field(credentials).finish(),
535 Self::MySQL(credentials) => f.debug_tuple("MySQL").field(credentials).finish(),
536 Self::D1 {
537 account_id,
538 database_id,
539 token,
540 } => f
541 .debug_struct("D1")
542 .field("account_id", account_id)
543 .field("database_id", database_id)
544 .field("token", &(!token.is_empty()).then_some("[REDACTED]"))
545 .finish(),
546 Self::AwsDataApi {
547 database,
548 secret_arn,
549 resource_arn,
550 } => f
551 .debug_struct("AwsDataApi")
552 .field("database", database)
553 .field("secret_arn", secret_arn)
554 .field("resource_arn", resource_arn)
555 .finish(),
556 }
557 }
558}
559
560impl std::fmt::Debug for PostgresCreds {
561 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
562 match self {
563 Self::Url(_) => f.debug_tuple("Url").field(&"[REDACTED]").finish(),
564 Self::Host {
565 host,
566 port,
567 user,
568 password,
569 database,
570 ssl,
571 } => f
572 .debug_struct("Host")
573 .field("host", host)
574 .field("port", port)
575 .field("user", user)
576 .field("password", &password.as_ref().map(|_| "[REDACTED]"))
577 .field("database", database)
578 .field("ssl", ssl)
579 .finish(),
580 }
581 }
582}
583
584impl std::fmt::Debug for MySQLCreds {
585 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
586 match self {
587 Self::Url(_) => f.debug_tuple("Url").field(&"[REDACTED]").finish(),
588 Self::Host {
589 host,
590 port,
591 user,
592 password,
593 database,
594 ssl,
595 } => f
596 .debug_struct("Host")
597 .field("host", host)
598 .field("port", port)
599 .field("user", user)
600 .field("password", &password.as_ref().map(|_| "[REDACTED]"))
601 .field("database", database)
602 .field("ssl", ssl)
603 .finish(),
604 }
605 }
606}
607
608#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
609pub enum PostgresSslMode {
610 #[default]
611 Disable,
612 Allow,
613 Prefer,
614 Require,
615 VerifyCa,
616 VerifyFull,
617}
618
619#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
621pub enum MySQLSslMode {
622 #[default]
623 Disable,
624 Required,
625 VerifyCa,
626 VerifyIdentity,
627}
628
629impl MySQLSslMode {
630 pub(crate) fn parse(value: &str) -> Result<Self, String> {
631 match value.trim().to_ascii_lowercase().as_str() {
632 "false" | "0" | "no" | "off" | "disable" | "disabled" => Ok(Self::Disable),
633 "true" | "1" | "yes" | "on" | "require" | "required" => Ok(Self::Required),
634 "verify-ca" => Ok(Self::VerifyCa),
635 "verify-identity" | "verify-full" => Ok(Self::VerifyIdentity),
636 _ => Err(format!("invalid MySQL SSL mode `{value}`")),
637 }
638 }
639}
640
641impl PostgresSslMode {
642 pub(crate) fn parse(value: &str) -> Result<Self, String> {
643 match value.trim().to_ascii_lowercase().as_str() {
644 "false" | "0" | "no" | "off" | "disable" => Ok(Self::Disable),
645 "allow" => Ok(Self::Allow),
646 "prefer" => Ok(Self::Prefer),
647 "true" | "1" | "yes" | "on" | "require" => Ok(Self::Require),
648 "verify-ca" => Ok(Self::VerifyCa),
649 "verify-full" => Ok(Self::VerifyFull),
650 _ => Err(format!("invalid PostgreSQL SSL mode `{value}`")),
651 }
652 }
653}
654
655#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
656pub struct PostgresConnectionConfig {
657 pub config: tokio_postgres::Config,
658 pub ssl: PostgresSslMode,
659}
660
661#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
662impl PostgresCreds {
663 pub fn connection_config(&self) -> Result<PostgresConnectionConfig, String> {
664 match self {
665 Self::Url(url) => {
666 let config = url
667 .parse::<tokio_postgres::Config>()
668 .map_err(|error| error.to_string())?;
669 let ssl = match config.get_ssl_mode() {
670 tokio_postgres::config::SslMode::Disable => PostgresSslMode::Disable,
671 tokio_postgres::config::SslMode::Prefer => PostgresSslMode::Prefer,
672 tokio_postgres::config::SslMode::Require => PostgresSslMode::Require,
673 _ => PostgresSslMode::Require,
674 };
675 Ok(PostgresConnectionConfig { config, ssl })
676 }
677 Self::Host {
678 host,
679 port,
680 user,
681 password,
682 database,
683 ssl,
684 } => {
685 let mut config = tokio_postgres::Config::new();
686 config
687 .host(host.as_ref())
688 .port(*port)
689 .dbname(database.as_ref());
690 if let Some(user) = user {
691 config.user(user.as_ref());
692 }
693 if let Some(password) = password {
694 config.password(password.as_bytes());
695 }
696 config.ssl_mode(match ssl {
697 PostgresSslMode::Disable => tokio_postgres::config::SslMode::Disable,
698 PostgresSslMode::Allow | PostgresSslMode::Prefer => {
699 tokio_postgres::config::SslMode::Prefer
700 }
701 PostgresSslMode::Require
702 | PostgresSslMode::VerifyCa
703 | PostgresSslMode::VerifyFull => tokio_postgres::config::SslMode::Require,
704 });
705 Ok(PostgresConnectionConfig { config, ssl: *ssl })
706 }
707 }
708 }
709}
710
711#[derive(Debug, Clone, Deserialize, JsonSchema)]
717#[serde(untagged)]
718pub enum Schema {
719 One(String),
720 Many(Vec<String>),
721}
722
723impl Default for Schema {
724 fn default() -> Self {
725 Self::One("src/schema.rs".into())
726 }
727}
728
729impl Schema {
730 pub fn iter(&self) -> impl Iterator<Item = &str> {
731 match self {
732 Self::One(s) => std::slice::from_ref(s).iter().map(String::as_str),
733 Self::Many(v) => v.iter().map(String::as_str),
734 }
735 }
736}
737
738#[derive(Debug, Clone, Deserialize, JsonSchema)]
740#[serde(untagged)]
741pub enum Filter {
742 One(String),
743 Many(Vec<String>),
744}
745
746impl Filter {
747 pub fn iter(&self) -> impl Iterator<Item = &str> {
748 match self {
749 Self::One(s) => std::slice::from_ref(s).iter().map(String::as_str),
750 Self::Many(v) => v.iter().map(String::as_str),
751 }
752 }
753}
754
755#[derive(Debug, Clone, Deserialize, JsonSchema)]
757pub struct MigrationsOpts {
758 pub table: Option<String>,
759 pub schema: Option<String>,
760 pub prefix: Option<MigrationPrefix>,
761 #[serde(default)]
768 pub bundle: Option<bool>,
769}
770
771#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)]
772#[serde(rename_all = "lowercase")]
773pub enum MigrationPrefix {
774 Index,
775 Timestamp,
776 Supabase,
777 Unix,
778 None,
779}
780
781#[derive(Debug, Clone, Deserialize, JsonSchema)]
786#[serde(untagged)]
787enum RawCreds {
788 D1 {
795 #[serde(rename = "accountId")]
796 account_id: ConfigValue,
797 #[serde(rename = "databaseId")]
798 database_id: ConfigValue,
799 token: ConfigValue,
800 },
801 AwsDataApi {
808 database: ConfigValue,
809 #[serde(rename = "secretArn")]
810 secret_arn: ConfigValue,
811 #[serde(rename = "resourceArn")]
812 resource_arn: ConfigValue,
813 },
814 Url {
815 url: ConfigValue,
816 #[serde(default, rename = "authToken")]
817 auth_token: Option<ConfigValue>,
818 },
819 Host {
820 host: ConfigValue,
821 #[serde(default)]
822 port: Option<u16>,
823 #[serde(default)]
824 user: Option<ConfigValue>,
825 #[serde(default)]
826 password: Option<ConfigValue>,
827 database: ConfigValue,
828 #[serde(default)]
829 ssl: Option<SslVal>,
830 },
831}
832
833#[derive(Debug, Clone, Deserialize, JsonSchema)]
834#[serde(untagged)]
835enum SslVal {
836 Bool(bool),
837 Str(String),
838}
839
840impl SslVal {
841 fn postgres_mode(&self) -> Result<PostgresSslMode, String> {
842 match self {
843 Self::Bool(false) => Ok(PostgresSslMode::Disable),
844 Self::Bool(true) => Ok(PostgresSslMode::Require),
845 Self::Str(value) => PostgresSslMode::parse(value),
846 }
847 }
848
849 fn mysql_mode(&self) -> Result<MySQLSslMode, String> {
850 match self {
851 Self::Bool(false) => Ok(MySQLSslMode::Disable),
852 Self::Bool(true) => Ok(MySQLSslMode::Required),
853 Self::Str(value) => MySQLSslMode::parse(value),
854 }
855 }
856}
857
858#[derive(Debug, Clone, Deserialize, JsonSchema)]
866#[serde(rename_all = "camelCase")]
867pub struct DatabaseConfig {
868 pub dialect: Dialect,
870
871 #[serde(default)]
873 pub schema: Schema,
874
875 #[serde(default = "default_out")]
877 pub out: PathBuf,
878
879 #[serde(default = "yes")]
881 pub breakpoints: bool,
882
883 #[serde(default)]
885 pub driver: Option<Driver>,
886
887 #[serde(default)]
889 db_credentials: Option<RawCreds>,
890
891 #[serde(default)]
893 pub tables_filter: Option<Filter>,
894
895 #[serde(default)]
897 pub schema_filter: Option<Filter>,
898
899 #[serde(default)]
901 pub extensions_filters: Option<Vec<Extension>>,
902
903 #[serde(default)]
905 pub entities: Option<EntitiesFilter>,
906
907 #[serde(default)]
909 pub casing: Option<Casing>,
910
911 #[serde(default)]
913 pub introspect: Option<IntrospectConfig>,
914
915 #[serde(default)]
917 pub verbose: bool,
918
919 #[serde(default)]
921 pub migrations: Option<MigrationsOpts>,
922}
923
924fn default_out() -> PathBuf {
925 PathBuf::from("./drizzle")
926}
927
928const fn yes() -> bool {
929 true
930}
931
932impl DatabaseConfig {
933 fn normalize_paths(&mut self, base_dir: &Path) {
934 if self.out.is_relative() {
937 self.out = base_dir.join(&self.out);
938 }
939
940 let base = base_dir.to_string_lossy().replace('\\', "/");
944 let base = base.trim_end_matches('/').to_string();
945
946 let normalize_one = |p: &str| -> String {
947 let p_trim = p.trim();
948 let is_abs = Path::new(p_trim).is_absolute() || p_trim.starts_with("\\\\");
949 let joined = if is_abs || base.is_empty() || base == "." {
950 p_trim.to_string()
951 } else {
952 format!("{base}/{p_trim}")
953 };
954 joined.replace('\\', "/")
955 };
956
957 match &mut self.schema {
958 Schema::One(p) => *p = normalize_one(p),
959 Schema::Many(v) => {
960 for p in v.iter_mut() {
961 *p = normalize_one(p);
962 }
963 }
964 }
965 }
966
967 fn validate(&self, name: &str) -> Result<(), Error> {
968 if let Some(d) = self.driver
970 && !d.is_valid_for(self.dialect)
971 {
972 return Err(Error::InvalidDriver {
973 driver: d,
974 dialect: self.dialect,
975 });
976 }
977
978 if let Some(ref raw) = self.db_credentials {
980 self.validate_creds(raw, name)?;
981 }
982
983 if self.dialect != Dialect::Postgresql {
985 if self.schema_filter.is_some() {
986 return Err(Error::InvalidConfig(
987 "schemaFilter is only supported for dialect = \"postgresql\"".into(),
988 ));
989 }
990 if self.extensions_filters.is_some() {
991 return Err(Error::InvalidConfig(
992 "extensionsFilters is only supported for dialect = \"postgresql\"".into(),
993 ));
994 }
995 if self.entities.is_some() {
996 return Err(Error::InvalidConfig(
997 "entities filter is only supported for dialect = \"postgresql\"".into(),
998 ));
999 }
1000 }
1001
1002 Ok(())
1003 }
1004
1005 fn validate_creds(&self, raw: &RawCreds, _name: &str) -> Result<(), Error> {
1006 let err = |msg: &str| Error::InvalidCredentials(msg.into());
1007
1008 match (self.dialect, raw) {
1011 (
1012 Dialect::Postgresql | Dialect::Mysql,
1013 RawCreds::Host { .. } | RawCreds::Url { .. },
1014 ) => {}
1015 (_, RawCreds::Host { .. }) => {
1016 return Err(err(
1017 "host-based dbCredentials are only supported for dialect = \"postgresql\" or \"mysql\"",
1018 ));
1019 }
1020 _ => {}
1021 }
1022
1023 if let RawCreds::D1 { .. } = raw {
1027 if self.dialect != Dialect::Sqlite {
1028 return Err(err(
1029 "D1 dbCredentials (accountId/databaseId/token) require dialect = \"sqlite\"",
1030 ));
1031 }
1032 if self.driver != Some(Driver::D1Http) {
1033 return Err(err(
1034 "D1 dbCredentials (accountId/databaseId/token) require driver = \"d1-http\"",
1035 ));
1036 }
1037 }
1038
1039 if self.driver == Some(Driver::D1Http) && !matches!(raw, RawCreds::D1 { .. }) {
1043 return Err(err(
1044 "driver = \"d1-http\" requires dbCredentials with accountId, databaseId, and token",
1045 ));
1046 }
1047
1048 if let RawCreds::AwsDataApi { .. } = raw {
1052 if self.dialect != Dialect::Postgresql {
1053 return Err(err(
1054 "AWS Data API dbCredentials (database/secretArn/resourceArn) require dialect = \"postgresql\"",
1055 ));
1056 }
1057 if self.driver != Some(Driver::AwsDataApi) {
1058 return Err(err(
1059 "AWS Data API dbCredentials (database/secretArn/resourceArn) require driver = \"aws-data-api\"",
1060 ));
1061 }
1062 }
1063
1064 if self.driver == Some(Driver::AwsDataApi) && !matches!(raw, RawCreds::AwsDataApi { .. }) {
1066 return Err(err(
1067 "driver = \"aws-data-api\" requires dbCredentials with database, secretArn, and resourceArn",
1068 ));
1069 }
1070
1071 match (self.dialect, raw) {
1073 (
1074 Dialect::Sqlite,
1075 RawCreds::Url {
1076 auth_token: Some(_),
1077 ..
1078 },
1079 ) => Err(err(
1080 "SQLite doesn't support authToken (use dialect = \"turso\")",
1081 )),
1082 (
1083 Dialect::Mysql,
1084 RawCreds::Url {
1085 auth_token: Some(_),
1086 ..
1087 },
1088 ) => Err(err("MySQL doesn't support authToken")),
1089 (
1090 Dialect::Sqlite,
1091 RawCreds::Url {
1092 url: ConfigValue::Inline(url),
1093 ..
1094 },
1095 ) if url.starts_with("libsql://") => Err(err(
1096 "libsql:// URLs require dialect = \"turso\" (for local SQLite files, use ./path.db)",
1097 )),
1098 (
1099 Dialect::Sqlite,
1100 RawCreds::Url {
1101 url: ConfigValue::Inline(url),
1102 ..
1103 },
1104 ) if url.starts_with("http://")
1105 || url.starts_with("https://")
1106 || url.starts_with("postgres://")
1107 || url.starts_with("postgresql://") =>
1108 {
1109 Err(err(
1110 "SQLite dbCredentials.url must be a local file path (not an http(s)/postgres URL)",
1111 ))
1112 }
1113 (
1114 Dialect::Turso,
1115 RawCreds::Url {
1116 url: ConfigValue::Inline(url),
1117 ..
1118 },
1119 ) if !url.starts_with("libsql://") && !url.starts_with("http") => {
1120 Err(err("Turso URL must start with libsql:// or http(s)://"))
1121 }
1122 (
1123 Dialect::Postgresql,
1124 RawCreds::Url {
1125 url: ConfigValue::Inline(url),
1126 ..
1127 },
1128 ) if !url.starts_with("postgres") => {
1129 Err(err("PostgreSQL URL must start with postgres://"))
1130 }
1131 (Dialect::Mysql, RawCreds::Host { ssl: Some(ssl), .. }) => ssl
1132 .mysql_mode()
1133 .map(|_| ())
1134 .map_err(Error::InvalidCredentials),
1135 (
1136 Dialect::Mysql,
1137 RawCreds::Url {
1138 url: ConfigValue::Inline(url),
1139 ..
1140 },
1141 ) if !url.starts_with("mysql://") => Err(err("MySQL URL must start with mysql://")),
1142 _ => Ok(()),
1143 }
1144 }
1145
1146 pub fn credentials(&self) -> Result<Option<Credentials>, Error> {
1154 let Some(raw) = self.db_credentials.as_ref() else {
1155 return Ok(None);
1156 };
1157
1158 let resolve_opt = |opt: &Option<ConfigValue>| -> Result<Option<Box<str>>, Error> {
1160 match opt.as_ref() {
1161 None => Ok(None),
1162 Some(e) => Ok(Some(e.resolve()?.into_boxed_str())),
1163 }
1164 };
1165
1166 let creds = match (self.dialect, raw) {
1167 (
1171 Dialect::Sqlite,
1172 RawCreds::D1 {
1173 account_id,
1174 database_id,
1175 token,
1176 },
1177 ) => Credentials::D1 {
1178 account_id: account_id.resolve()?.into_boxed_str(),
1179 database_id: database_id.resolve()?.into_boxed_str(),
1180 token: token.resolve()?.into_boxed_str(),
1181 },
1182 (
1185 Dialect::Postgresql,
1186 RawCreds::AwsDataApi {
1187 database,
1188 secret_arn,
1189 resource_arn,
1190 },
1191 ) => Credentials::AwsDataApi {
1192 database: database.resolve()?.into_boxed_str(),
1193 secret_arn: secret_arn.resolve()?.into_boxed_str(),
1194 resource_arn: resource_arn.resolve()?.into_boxed_str(),
1195 },
1196 (Dialect::Sqlite, RawCreds::Url { url, .. }) => Credentials::Sqlite {
1198 path: url.resolve()?.into_boxed_str(),
1199 },
1200 (Dialect::Turso, RawCreds::Url { url, auth_token }) => Credentials::Turso {
1202 url: url.resolve()?.into_boxed_str(),
1203 auth_token: resolve_opt(auth_token)?,
1204 },
1205 (Dialect::Postgresql, RawCreds::Url { url, .. }) => {
1207 Credentials::Postgres(PostgresCreds::Url(url.resolve()?.into_boxed_str()))
1208 }
1209 (
1211 Dialect::Mysql,
1212 RawCreds::Url {
1213 url,
1214 auth_token: None,
1215 },
1216 ) => Credentials::MySQL(MySQLCreds::Url(url.resolve()?.into_boxed_str())),
1217 (
1219 Dialect::Postgresql,
1220 RawCreds::Host {
1221 host,
1222 port,
1223 user,
1224 password,
1225 database,
1226 ssl,
1227 },
1228 ) => Credentials::Postgres(PostgresCreds::Host {
1229 host: host.resolve()?.into_boxed_str(),
1230 port: port.unwrap_or(5432),
1231 user: resolve_opt(user)?,
1232 password: resolve_opt(password)?,
1233 database: database.resolve()?.into_boxed_str(),
1234 ssl: ssl
1235 .as_ref()
1236 .map_or(Ok(PostgresSslMode::Disable), SslVal::postgres_mode)
1237 .map_err(Error::InvalidCredentials)?,
1238 }),
1239 (
1241 Dialect::Mysql,
1242 RawCreds::Host {
1243 host,
1244 port,
1245 user,
1246 password,
1247 database,
1248 ssl,
1249 },
1250 ) => Credentials::MySQL(MySQLCreds::Host {
1251 host: host.resolve()?.into_boxed_str(),
1252 port: port.unwrap_or(3306),
1253 user: resolve_opt(user)?,
1254 password: resolve_opt(password)?,
1255 database: database.resolve()?.into_boxed_str(),
1256 ssl: ssl
1257 .as_ref()
1258 .map_or(Ok(MySQLSslMode::Disable), SslVal::mysql_mode)
1259 .map_err(Error::InvalidCredentials)?,
1260 }),
1261 _ => return Ok(None),
1262 };
1263
1264 Ok(Some(creds))
1265 }
1266
1267 #[inline]
1269 #[must_use]
1270 pub fn migrations_dir(&self) -> &Path {
1271 &self.out
1272 }
1273
1274 #[inline]
1276 #[must_use]
1277 pub fn meta_dir(&self) -> PathBuf {
1278 self.out.join("meta")
1279 }
1280
1281 #[inline]
1283 #[must_use]
1284 pub fn journal_path(&self) -> PathBuf {
1285 self.meta_dir().join("_journal.json")
1286 }
1287
1288 #[must_use]
1290 pub fn schema_display(&self) -> String {
1291 match &self.schema {
1292 Schema::One(s) => s.clone(),
1293 Schema::Many(v) => v.join(", "),
1294 }
1295 }
1296
1297 pub fn schema_files(&self) -> Result<Vec<PathBuf>, Error> {
1305 let files = resolve_schema_patterns(self.schema.iter())?;
1306 if files.is_empty() {
1307 return Err(Error::NoSchemaFiles(self.schema_display()));
1308 }
1309 Ok(files)
1310 }
1311
1312 #[inline]
1314 #[must_use]
1315 pub fn effective_casing(&self) -> Casing {
1316 self.casing.unwrap_or_default()
1317 }
1318
1319 #[inline]
1321 #[must_use]
1322 pub fn effective_introspect_casing(&self) -> IntrospectCasing {
1323 self.introspect
1324 .as_ref()
1325 .map(|i| i.casing)
1326 .unwrap_or_default()
1327 }
1328
1329 #[inline]
1331 #[must_use]
1332 pub fn effective_entities(&self) -> EntitiesFilter {
1333 self.entities.clone().unwrap_or_default()
1334 }
1335
1336 #[must_use]
1338 pub fn should_include_role(&self, role_name: &str) -> bool {
1339 self.entities
1340 .as_ref()
1341 .is_some_and(|e| e.roles.should_include(role_name))
1342 }
1343
1344 #[must_use]
1346 pub fn roles_enabled(&self) -> bool {
1347 self.entities.as_ref().is_some_and(|e| e.roles.is_enabled())
1348 }
1349
1350 #[must_use]
1352 pub fn extensions(&self) -> &[Extension] {
1353 self.extensions_filters.as_deref().unwrap_or(&[])
1354 }
1355
1356 #[must_use]
1358 pub fn has_extension(&self, ext: Extension) -> bool {
1359 self.extensions_filters
1360 .as_ref()
1361 .is_some_and(|v| v.contains(&ext))
1362 }
1363
1364 #[must_use]
1366 pub fn migrations_table(&self) -> &str {
1367 self.migrations
1368 .as_ref()
1369 .and_then(|m| m.table.as_deref())
1370 .unwrap_or("__drizzle_migrations")
1371 }
1372
1373 #[must_use]
1375 pub fn migrations_schema(&self) -> &str {
1376 self.migrations
1377 .as_ref()
1378 .and_then(|m| m.schema.as_deref())
1379 .unwrap_or("drizzle")
1380 }
1381
1382 #[must_use]
1391 pub fn bundle_enabled(&self) -> bool {
1392 if let Some(explicit) = self.migrations.as_ref().and_then(|m| m.bundle) {
1393 return explicit;
1394 }
1395 matches!(self.driver, Some(Driver::DurableSqlite))
1396 }
1397}
1398
1399#[derive(Debug, Clone, Deserialize)]
1405struct MultiDbConfig {
1406 databases: HashMap<String, DatabaseConfig>,
1407}
1408
1409#[derive(Debug, Clone)]
1433pub struct Config {
1434 databases: HashMap<String, DatabaseConfig>,
1436 is_single: bool,
1438}
1439
1440pub const DEFAULT_DB: &str = "default";
1442
1443impl Config {
1444 pub fn load() -> Result<Self, Error> {
1451 Self::load_from(Path::new(CONFIG_FILE))
1452 }
1453
1454 pub fn load_from(path: &Path) -> Result<Self, Error> {
1462 let content = std::fs::read_to_string(path).map_err(|e| {
1463 if e.kind() == std::io::ErrorKind::NotFound {
1464 Error::NotFound(path.into())
1465 } else {
1466 Error::Io(path.into(), e)
1467 }
1468 })?;
1469
1470 Self::load_from_str(&content, path)
1471 }
1472
1473 fn load_from_str(content: &str, path: &Path) -> Result<Self, Error> {
1475 let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
1476
1477 if let Ok(multi) = toml::from_str::<MultiDbConfig>(content)
1479 && !multi.databases.is_empty()
1480 {
1481 let mut config = Self {
1482 databases: multi.databases,
1483 is_single: false,
1484 };
1485 for db in config.databases.values_mut() {
1486 db.normalize_paths(base_dir);
1487 }
1488 config.validate()?;
1489 return Ok(config);
1490 }
1491
1492 let db_config: DatabaseConfig =
1494 toml::from_str(content).map_err(|e| Error::Parse(path.into(), e))?;
1495
1496 let mut databases = HashMap::new();
1497 databases.insert(DEFAULT_DB.to_string(), db_config);
1498
1499 let mut config = Self {
1500 databases,
1501 is_single: true,
1502 };
1503 for db in config.databases.values_mut() {
1504 db.normalize_paths(base_dir);
1505 }
1506 config.validate()?;
1507 Ok(config)
1508 }
1509
1510 fn validate(&self) -> Result<(), Error> {
1511 for (name, db) in &self.databases {
1512 db.validate(name)?;
1513 }
1514 Ok(())
1515 }
1516
1517 #[must_use]
1519 pub const fn is_single_database(&self) -> bool {
1520 self.is_single
1521 }
1522
1523 pub fn database_names(&self) -> impl Iterator<Item = &str> {
1525 self.databases.keys().map(String::as_str)
1526 }
1527
1528 pub fn database(&self, name: Option<&str>) -> Result<&DatabaseConfig, Error> {
1540 name.map_or_else(
1541 || {
1542 if self.is_single {
1544 self.databases.get(DEFAULT_DB).ok_or(Error::NoDatabases)
1545 } else if self.databases.len() == 1 {
1546 self.databases.values().next().ok_or(Error::NoDatabases)
1547 } else {
1548 Err(Error::DatabaseRequired(
1549 self.databases.keys().cloned().collect(),
1550 ))
1551 }
1552 },
1553 |name| {
1554 if self.is_single {
1555 self.databases.get(DEFAULT_DB).ok_or(Error::NoDatabases)
1557 } else {
1558 self.databases
1559 .get(name)
1560 .ok_or_else(|| Error::DatabaseNotFound(name.to_string()))
1561 }
1562 },
1563 )
1564 }
1565
1566 pub fn default_database(&self) -> Result<&DatabaseConfig, Error> {
1572 self.database(None)
1573 }
1574
1575 #[must_use]
1581 pub fn dialect(&self) -> Dialect {
1582 self.default_database()
1583 .map(|d| d.dialect)
1584 .unwrap_or_default()
1585 }
1586
1587 pub fn credentials(&self) -> Result<Option<Credentials>, Error> {
1594 self.default_database()?.credentials()
1595 }
1596
1597 #[must_use]
1599 pub fn migrations_dir(&self) -> &Path {
1600 self.default_database()
1601 .map_or_else(|_| Path::new("./drizzle"), |d| d.migrations_dir())
1602 }
1603
1604 #[must_use]
1606 pub fn journal_path(&self) -> PathBuf {
1607 self.default_database().map_or_else(
1608 |_| PathBuf::from("./drizzle/meta/_journal.json"),
1609 DatabaseConfig::journal_path,
1610 )
1611 }
1612
1613 #[must_use]
1615 pub fn schema_display(&self) -> String {
1616 self.default_database()
1617 .map_or_else(|_| "src/schema.rs".into(), DatabaseConfig::schema_display)
1618 }
1619
1620 pub fn schema_files(&self) -> Result<Vec<PathBuf>, Error> {
1627 self.default_database()?.schema_files()
1628 }
1629
1630 #[must_use]
1632 pub fn base_dialect(&self) -> drizzle_types::Dialect {
1633 self.dialect().to_base()
1634 }
1635}
1636
1637pub fn resolve_schema_patterns<'a>(
1651 patterns: impl IntoIterator<Item = &'a str>,
1652) -> Result<Vec<PathBuf>, Error> {
1653 let mut files = Vec::new();
1654
1655 for pattern in patterns {
1656 let pat = pattern.trim();
1657 let is_glob = pat.contains('*') || pat.contains('?') || pat.contains('[');
1658
1659 if !is_glob {
1660 let path = PathBuf::from(pat);
1662 let normalized = PathBuf::from(pat.replace('\\', "/"));
1663 if path.is_file() {
1664 files.push(path);
1665 } else if normalized.is_file() {
1666 files.push(normalized);
1667 } else {
1668 return Err(Error::SchemaPathNotFound(pat.into()));
1669 }
1670 continue;
1671 }
1672
1673 let pat_norm = pat.replace('\\', "/");
1675 let paths = glob::glob(&pat_norm).map_err(|e| Error::Glob(pat.into(), e))?;
1676 let mut matched = false;
1677 for entry in paths {
1678 let path = entry.map_err(|e| Error::GlobRead(pat.into(), e))?;
1679 if path.is_file() {
1681 files.push(path);
1682 matched = true;
1683 }
1684 }
1685 if !matched {
1686 return Err(Error::SchemaPatternMatchedNothing(pat.into()));
1687 }
1688 }
1689
1690 files.sort();
1691 files.dedup();
1692 Ok(files)
1693}
1694
1695#[derive(Debug, thiserror::Error)]
1700pub enum Error {
1701 #[error("config not found: {}", .0.display())]
1702 NotFound(PathBuf),
1703
1704 #[error("failed to read {}: {}", .0.display(), .1)]
1705 Io(PathBuf, #[source] std::io::Error),
1706
1707 #[error("failed to parse {}: {}", .0.display(), .1)]
1708 Parse(PathBuf, #[source] toml::de::Error),
1709
1710 #[error("driver '{driver}' invalid for {dialect} dialect")]
1711 InvalidDriver { driver: Driver, dialect: Dialect },
1712
1713 #[error("invalid credentials: {0}")]
1714 InvalidCredentials(String),
1715
1716 #[error("invalid config: {0}")]
1717 InvalidConfig(String),
1718
1719 #[error("invalid glob '{0}': {1}")]
1720 Glob(String, #[source] glob::PatternError),
1721
1722 #[error("failed to read a path matched by glob '{0}': {1}")]
1723 GlobRead(String, #[source] glob::GlobError),
1724
1725 #[error("schema path '{0}' does not exist")]
1726 SchemaPathNotFound(String),
1727
1728 #[error("schema pattern '{0}' matches no file")]
1729 SchemaPatternMatchedNothing(String),
1730
1731 #[error("no schema files found: {0}")]
1732 NoSchemaFiles(String),
1733
1734 #[error("environment variable '{0}' not found")]
1735 EnvNotFound(String),
1736
1737 #[error("environment variable '{0}' invalid: {1}")]
1738 EnvInvalid(String, String),
1739
1740 #[error("no databases configured")]
1741 NoDatabases,
1742
1743 #[error("database '{0}' not found")]
1744 DatabaseNotFound(String),
1745
1746 #[error("multiple databases configured, use --db to specify: {}", .0.join(", "))]
1747 DatabaseRequired(Vec<String>),
1748}
1749
1750impl From<ConfigValueError> for Error {
1751 fn from(err: ConfigValueError) -> Self {
1752 match err {
1753 ConfigValueError::NotPresent(var) => Self::EnvNotFound(var),
1754 ConfigValueError::NotUnicode(var) => {
1755 Self::EnvInvalid(var, "contains invalid unicode".into())
1756 }
1757 }
1758 }
1759}
1760
1761#[cfg(test)]
1766mod tests {
1767 use super::*;
1768 use std::fs;
1769 use tempfile::TempDir;
1770
1771 #[test]
1772 fn sqlite() {
1773 let cfg = Config::load_from_str(
1774 r#"
1775 dialect = "sqlite"
1776 [dbCredentials]
1777 url = "./dev.db"
1778 "#,
1779 Path::new("test.toml"),
1780 )
1781 .unwrap();
1782 assert!(cfg.is_single_database());
1783 assert!(matches!(
1784 cfg.credentials().unwrap(),
1785 Some(Credentials::Sqlite { .. })
1786 ));
1787 }
1788
1789 #[test]
1790 fn postgres_url() {
1791 let cfg = Config::load_from_str(
1792 r#"
1793 dialect = "postgresql"
1794 [dbCredentials]
1795 url = "postgres://localhost/db"
1796 "#,
1797 Path::new("test.toml"),
1798 )
1799 .unwrap();
1800 assert!(matches!(
1801 cfg.credentials().unwrap(),
1802 Some(Credentials::Postgres(PostgresCreds::Url(_)))
1803 ));
1804 }
1805
1806 #[test]
1807 fn mysql_url_credentials_and_driver_parse() {
1808 let cfg = Config::load_from_str(
1809 r#"
1810 dialect = "mysql"
1811 driver = "mysql-async"
1812 [dbCredentials]
1813 url = "mysql://user:secret@localhost:3306/app"
1814 "#,
1815 Path::new("test.toml"),
1816 )
1817 .unwrap();
1818 let db = cfg.default_database().unwrap();
1819 assert_eq!(db.dialect, Dialect::Mysql);
1820 assert_eq!(db.driver, Some(Driver::MysqlAsync));
1821 assert!(matches!(
1822 db.credentials().unwrap(),
1823 Some(Credentials::MySQL(MySQLCreds::Url(_)))
1824 ));
1825 }
1826
1827 #[test]
1828 fn mysql_host_credentials_use_mysql_defaults() {
1829 let cfg = Config::load_from_str(
1830 r#"
1831 dialect = "mysql"
1832 driver = "mysql-sync"
1833 [dbCredentials]
1834 host = "db.example.com"
1835 user = "app"
1836 password = "secret"
1837 database = "app_db"
1838 ssl = "verify-identity"
1839 "#,
1840 Path::new("test.toml"),
1841 )
1842 .unwrap();
1843 match cfg.default_database().unwrap().credentials().unwrap() {
1844 Some(Credentials::MySQL(MySQLCreds::Host {
1845 port,
1846 ssl,
1847 database,
1848 ..
1849 })) => {
1850 assert_eq!(port, 3306);
1851 assert_eq!(ssl, MySQLSslMode::VerifyIdentity);
1852 assert_eq!(database.as_ref(), "app_db");
1853 }
1854 other => panic!("unexpected credentials: {other:?}"),
1855 }
1856 }
1857
1858 #[test]
1859 fn mysql_host_credentials_reject_unrepresentable_preferred_tls() {
1860 let error = Config::load_from_str(
1861 r#"
1862 dialect = "mysql"
1863 [dbCredentials]
1864 host = "db.example.com"
1865 database = "app_db"
1866 ssl = "preferred"
1867 "#,
1868 Path::new("test.toml"),
1869 )
1870 .expect_err("host credentials cannot express opportunistic TLS");
1871
1872 assert!(
1873 error.to_string().contains("invalid MySQL SSL mode"),
1874 "{error}"
1875 );
1876 }
1877
1878 #[test]
1879 fn mysql_rejects_cross_dialect_driver_and_url() {
1880 let driver_error = Config::load_from_str(
1881 r#"
1882 dialect = "mysql"
1883 driver = "postgres-sync"
1884 [dbCredentials]
1885 url = "mysql://localhost/app"
1886 "#,
1887 Path::new("test.toml"),
1888 )
1889 .expect_err("postgres driver must not target mysql");
1890 assert!(driver_error.to_string().contains("invalid for mysql"));
1891
1892 let url_error = Config::load_from_str(
1893 r#"
1894 dialect = "mysql"
1895 [dbCredentials]
1896 url = "postgres://localhost/app"
1897 "#,
1898 Path::new("test.toml"),
1899 )
1900 .expect_err("postgres URL must not target mysql");
1901 assert!(url_error.to_string().contains("must start with mysql://"));
1902 }
1903
1904 #[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
1905 #[test]
1906 fn postgres_host_credentials_are_structured_without_url_encoding() {
1907 let credentials = PostgresCreds::Host {
1908 host: "db.example.com".into(),
1909 port: 5432,
1910 user: Some("user@tenant".into()),
1911 password: Some("p:a/ss% word".into()),
1912 database: "app/db name".into(),
1913 ssl: PostgresSslMode::VerifyFull,
1914 };
1915
1916 let connection = credentials.connection_config().expect("build config");
1917 assert_eq!(connection.config.get_user(), Some("user@tenant"));
1918 assert_eq!(connection.config.get_password(), Some(&b"p:a/ss% word"[..]));
1919 assert_eq!(connection.config.get_dbname(), Some("app/db name"));
1920 assert_eq!(
1921 connection.config.get_ssl_mode(),
1922 tokio_postgres::config::SslMode::Require
1923 );
1924 assert_eq!(connection.ssl, PostgresSslMode::VerifyFull);
1925 }
1926
1927 #[test]
1928 fn multi_database() {
1929 let cfg = Config::load_from_str(
1930 r#"
1931 [databases.dev]
1932 dialect = "sqlite"
1933 out = "./drizzle/sqlite"
1934 [databases.dev.dbCredentials]
1935 url = "./dev.db"
1936
1937 [databases.prod]
1938 dialect = "postgresql"
1939 out = "./drizzle/postgres"
1940 [databases.prod.dbCredentials]
1941 url = "postgres://localhost/db"
1942 "#,
1943 Path::new("test.toml"),
1944 )
1945 .unwrap();
1946
1947 assert!(!cfg.is_single_database());
1948 let names: Vec<_> = cfg.database_names().collect();
1949 assert!(names.contains(&"dev"));
1950 assert!(names.contains(&"prod"));
1951
1952 let dev = cfg.database(Some("dev")).unwrap();
1953 assert_eq!(dev.dialect, Dialect::Sqlite);
1954
1955 let prod = cfg.database(Some("prod")).unwrap();
1956 assert_eq!(prod.dialect, Dialect::Postgresql);
1957 }
1958
1959 #[test]
1960 fn multi_database_requires_selection() {
1961 let cfg = Config::load_from_str(
1962 r#"
1963 [databases.a]
1964 dialect = "sqlite"
1965 [databases.b]
1966 dialect = "postgresql"
1967 "#,
1968 Path::new("test.toml"),
1969 )
1970 .unwrap();
1971
1972 assert!(cfg.database(None).is_err());
1974 }
1975
1976 #[test]
1977 fn env_var_syntax() {
1978 let cfg = Config::load_from_str(
1979 r#"
1980 dialect = "postgresql"
1981 [dbCredentials]
1982 url = { env = "DATABASE_URL" }
1983 "#,
1984 Path::new("test.toml"),
1985 )
1986 .unwrap();
1987 assert!(cfg.is_single_database());
1988 }
1989
1990 #[test]
1991 fn casing_options() {
1992 let cfg = Config::load_from_str(
1993 r#"
1994 dialect = "postgresql"
1995 casing = "snake_case"
1996 [dbCredentials]
1997 url = "postgres://localhost/db"
1998 "#,
1999 Path::new("test.toml"),
2000 )
2001 .unwrap();
2002 let db = cfg.default_database().unwrap();
2003 assert_eq!(db.effective_casing(), Casing::SnakeCase);
2004
2005 let cfg2 = Config::load_from_str(
2007 r#"
2008 dialect = "postgresql"
2009 [dbCredentials]
2010 url = "postgres://localhost/db"
2011 "#,
2012 Path::new("test.toml"),
2013 )
2014 .unwrap();
2015 let db2 = cfg2.default_database().unwrap();
2016 assert_eq!(db2.effective_casing(), Casing::CamelCase);
2017 }
2018
2019 #[test]
2020 fn introspect_casing() {
2021 let cfg = Config::load_from_str(
2022 r#"
2023 dialect = "postgresql"
2024 [introspect]
2025 casing = "preserve"
2026 [dbCredentials]
2027 url = "postgres://localhost/db"
2028 "#,
2029 Path::new("test.toml"),
2030 )
2031 .unwrap();
2032 let db = cfg.default_database().unwrap();
2033 assert_eq!(db.effective_introspect_casing(), IntrospectCasing::Preserve);
2034 }
2035
2036 #[test]
2037 fn entities_roles_filter() {
2038 let cfg = Config::load_from_str(
2040 r#"
2041 dialect = "postgresql"
2042 [entities]
2043 roles = true
2044 [dbCredentials]
2045 url = "postgres://localhost/db"
2046 "#,
2047 Path::new("test.toml"),
2048 )
2049 .unwrap();
2050 let db = cfg.default_database().unwrap();
2051 assert!(db.roles_enabled());
2052 assert!(db.should_include_role("my_role"));
2053
2054 let cfg2 = Config::load_from_str(
2056 r#"
2057 dialect = "postgresql"
2058 [entities.roles]
2059 provider = "supabase"
2060 [dbCredentials]
2061 url = "postgres://localhost/db"
2062 "#,
2063 Path::new("test.toml"),
2064 )
2065 .unwrap();
2066 let db2 = cfg2.default_database().unwrap();
2067 assert!(db2.roles_enabled());
2068 assert!(!db2.should_include_role("anon")); assert!(db2.should_include_role("my_custom_role"));
2070 }
2071
2072 #[test]
2073 fn extensions_filter() {
2074 let cfg = Config::load_from_str(
2075 r#"
2076 dialect = "postgresql"
2077 extensionsFilters = ["postgis"]
2078 [dbCredentials]
2079 url = "postgres://localhost/db"
2080 "#,
2081 Path::new("test.toml"),
2082 )
2083 .unwrap();
2084 let db = cfg.default_database().unwrap();
2085 assert!(db.has_extension(Extension::Postgis));
2086 }
2087
2088 #[test]
2089 fn rejects_postgres_only_filters_for_sqlite() {
2090 let err = Config::load_from_str(
2091 r#"
2092 dialect = "sqlite"
2093 schemaFilter = ["public"]
2094 [dbCredentials]
2095 url = "./dev.db"
2096 "#,
2097 Path::new("test.toml"),
2098 )
2099 .expect_err("sqlite should reject schemaFilter");
2100 assert_eq!(
2101 err.to_string(),
2102 "invalid config: schemaFilter is only supported for dialect = \"postgresql\""
2103 );
2104
2105 let err = Config::load_from_str(
2106 r#"
2107 dialect = "sqlite"
2108 extensionsFilters = ["postgis"]
2109 [dbCredentials]
2110 url = "./dev.db"
2111 "#,
2112 Path::new("test.toml"),
2113 )
2114 .expect_err("sqlite should reject extensionsFilters");
2115 assert_eq!(
2116 err.to_string(),
2117 "invalid config: extensionsFilters is only supported for dialect = \"postgresql\""
2118 );
2119 }
2120
2121 #[test]
2122 fn rejects_entities_filter_for_turso() {
2123 let err = Config::load_from_str(
2124 r#"
2125 dialect = "turso"
2126 [entities]
2127 roles = true
2128 [dbCredentials]
2129 url = "libsql://example.turso.io"
2130 "#,
2131 Path::new("test.toml"),
2132 )
2133 .expect_err("turso should reject entities filter");
2134 assert_eq!(
2135 err.to_string(),
2136 "invalid config: entities filter is only supported for dialect = \"postgresql\""
2137 );
2138 }
2139
2140 #[test]
2141 fn migrations_config() {
2142 let cfg = Config::load_from_str(
2143 r#"
2144 dialect = "postgresql"
2145 [migrations]
2146 table = "custom_migrations"
2147 schema = "custom_schema"
2148 [dbCredentials]
2149 url = "postgres://localhost/db"
2150 "#,
2151 Path::new("test.toml"),
2152 )
2153 .unwrap();
2154 let db = cfg.default_database().unwrap();
2155 assert_eq!(db.migrations_table(), "custom_migrations");
2156 assert_eq!(db.migrations_schema(), "custom_schema");
2157
2158 let cfg2 = Config::load_from_str(
2160 r#"
2161 dialect = "postgresql"
2162 [dbCredentials]
2163 url = "postgres://localhost/db"
2164 "#,
2165 Path::new("test.toml"),
2166 )
2167 .unwrap();
2168 let db2 = cfg2.default_database().unwrap();
2169 assert_eq!(db2.migrations_table(), "__drizzle_migrations");
2170 assert_eq!(db2.migrations_schema(), "drizzle");
2171 }
2172
2173 #[test]
2174 fn resolves_paths_relative_to_config_dir() {
2175 let tmp = TempDir::new().unwrap();
2176 let cfg_dir = tmp.path().join("cfg");
2177 fs::create_dir_all(&cfg_dir).unwrap();
2178
2179 let schema_path = cfg_dir.join("schema.rs");
2181 fs::write(&schema_path, "#[allow(dead_code)]\npub struct X;").unwrap();
2182
2183 let cfg_path = cfg_dir.join("drizzle.config.toml");
2184 let cfg = Config::load_from_str(
2185 r#"
2186 dialect = "sqlite"
2187 schema = "schema.rs"
2188 out = "./drizzle"
2189 [dbCredentials]
2190 url = "./dev.db"
2191 "#,
2192 &cfg_path,
2193 )
2194 .unwrap();
2195
2196 let db = cfg.default_database().unwrap();
2197 assert_eq!(db.migrations_dir(), cfg_dir.join("./drizzle").as_path());
2198
2199 let files = db.schema_files().unwrap();
2200 assert_eq!(files.len(), 1);
2201 assert_eq!(files[0], schema_path);
2202 }
2203
2204 #[test]
2205 fn rejects_host_credentials_for_sqlite() {
2206 let err = Config::load_from_str(
2207 r#"
2208 dialect = "sqlite"
2209 [dbCredentials]
2210 host = "localhost"
2211 database = "db"
2212 "#,
2213 Path::new("test.toml"),
2214 )
2215 .unwrap_err();
2216
2217 assert_eq!(
2218 err.to_string(),
2219 "invalid credentials: host-based dbCredentials are only supported for dialect = \"postgresql\" or \"mysql\""
2220 );
2221 }
2222
2223 #[test]
2228 fn d1_http_credentials_parse() {
2229 let cfg = Config::load_from_str(
2230 r#"
2231 dialect = "sqlite"
2232 driver = "d1-http"
2233 [dbCredentials]
2234 accountId = "acc_abc"
2235 databaseId = "db_xyz"
2236 token = "tok_123"
2237 "#,
2238 Path::new("test.toml"),
2239 )
2240 .unwrap();
2241
2242 let db = cfg.default_database().unwrap();
2243 assert_eq!(db.driver, Some(Driver::D1Http));
2244 match db.credentials().unwrap() {
2245 Some(Credentials::D1 {
2246 account_id,
2247 database_id,
2248 token,
2249 }) => {
2250 assert_eq!(&*account_id, "acc_abc");
2251 assert_eq!(&*database_id, "db_xyz");
2252 assert_eq!(&*token, "tok_123");
2253 }
2254 other => panic!("expected Credentials::D1, got {other:?}"),
2255 }
2256 }
2257
2258 #[test]
2259 fn d1_http_credentials_resolve_from_env() {
2260 unsafe {
2262 std::env::set_var("TEST_D1_ACCT", "env_acct");
2263 std::env::set_var("TEST_D1_DB", "env_db");
2264 std::env::set_var("TEST_D1_TOKEN", "env_token");
2265 }
2266 let cfg = Config::load_from_str(
2267 r#"
2268 dialect = "sqlite"
2269 driver = "d1-http"
2270 [dbCredentials]
2271 accountId = { env = "TEST_D1_ACCT" }
2272 databaseId = { env = "TEST_D1_DB" }
2273 token = { env = "TEST_D1_TOKEN" }
2274 "#,
2275 Path::new("test.toml"),
2276 )
2277 .unwrap();
2278
2279 match cfg.default_database().unwrap().credentials().unwrap() {
2280 Some(Credentials::D1 {
2281 account_id,
2282 database_id,
2283 token,
2284 }) => {
2285 assert_eq!(&*account_id, "env_acct");
2286 assert_eq!(&*database_id, "env_db");
2287 assert_eq!(&*token, "env_token");
2288 }
2289 other => panic!("expected Credentials::D1, got {other:?}"),
2290 }
2291 }
2292
2293 #[test]
2294 fn d1_credentials_require_sqlite_dialect() {
2295 let err = Config::load_from_str(
2296 r#"
2297 dialect = "postgresql"
2298 [dbCredentials]
2299 accountId = "acc"
2300 databaseId = "db"
2301 token = "tok"
2302 "#,
2303 Path::new("test.toml"),
2304 )
2305 .unwrap_err();
2306 assert!(
2307 err.to_string().contains("D1 dbCredentials"),
2308 "expected D1-specific error, got: {err}"
2309 );
2310 }
2311
2312 #[test]
2313 fn d1_credentials_require_d1_http_driver() {
2314 let err = Config::load_from_str(
2316 r#"
2317 dialect = "sqlite"
2318 driver = "rusqlite"
2319 [dbCredentials]
2320 accountId = "acc"
2321 databaseId = "db"
2322 token = "tok"
2323 "#,
2324 Path::new("test.toml"),
2325 )
2326 .unwrap_err();
2327 assert!(
2328 err.to_string().contains("driver = \"d1-http\""),
2329 "expected d1-http driver error, got: {err}"
2330 );
2331 }
2332
2333 #[test]
2334 fn d1_http_driver_requires_d1_credentials() {
2335 let err = Config::load_from_str(
2337 r#"
2338 dialect = "sqlite"
2339 driver = "d1-http"
2340 [dbCredentials]
2341 url = "./dev.db"
2342 "#,
2343 Path::new("test.toml"),
2344 )
2345 .unwrap_err();
2346 assert!(
2347 err.to_string().contains("accountId, databaseId, and token"),
2348 "expected d1-http creds-shape error, got: {err}"
2349 );
2350 }
2351
2352 #[test]
2353 fn durable_sqlite_no_credentials_ok() {
2354 let cfg = Config::load_from_str(
2357 r#"
2358 dialect = "sqlite"
2359 driver = "durable-sqlite"
2360 "#,
2361 Path::new("test.toml"),
2362 )
2363 .unwrap();
2364
2365 let db = cfg.default_database().unwrap();
2366 assert_eq!(db.driver, Some(Driver::DurableSqlite));
2367 assert!(db.credentials().unwrap().is_none());
2368 assert!(
2370 db.bundle_enabled(),
2371 "durable-sqlite should auto-enable bundle"
2372 );
2373 }
2374
2375 #[test]
2376 fn durable_sqlite_explicit_bundle_false_respected() {
2377 let cfg = Config::load_from_str(
2379 r#"
2380 dialect = "sqlite"
2381 driver = "durable-sqlite"
2382 [migrations]
2383 bundle = false
2384 "#,
2385 Path::new("test.toml"),
2386 )
2387 .unwrap();
2388 assert!(!cfg.default_database().unwrap().bundle_enabled());
2389 }
2390
2391 #[test]
2392 fn durable_sqlite_rejects_non_sqlite_dialect() {
2393 let err = Config::load_from_str(
2394 r#"
2395 dialect = "postgresql"
2396 driver = "durable-sqlite"
2397 [dbCredentials]
2398 url = "postgres://localhost/db"
2399 "#,
2400 Path::new("test.toml"),
2401 )
2402 .unwrap_err();
2403 assert!(
2404 err.to_string().contains("invalid for postgresql"),
2405 "expected dialect/driver mismatch error, got: {err}"
2406 );
2407 }
2408
2409 #[test]
2410 fn driver_valid_for_sqlite_includes_cloudflare() {
2411 let drivers = Driver::valid_for(Dialect::Sqlite);
2412 assert!(drivers.contains(&Driver::Rusqlite));
2413 assert!(drivers.contains(&Driver::D1Http));
2414 assert!(drivers.contains(&Driver::DurableSqlite));
2415 for drv in [Driver::D1Http, Driver::DurableSqlite] {
2417 assert!(!drv.is_valid_for(Dialect::Postgresql));
2418 assert!(!drv.is_valid_for(Dialect::Turso));
2419 }
2420 }
2421
2422 #[test]
2423 fn driver_is_codegen_only_flag() {
2424 assert!(Driver::DurableSqlite.is_codegen_only());
2425 assert!(!Driver::D1Http.is_codegen_only());
2426 assert!(!Driver::Rusqlite.is_codegen_only());
2427 assert!(!Driver::AwsDataApi.is_codegen_only());
2428 }
2429
2430 #[test]
2435 fn aws_data_api_credentials_parse() {
2436 let cfg = Config::load_from_str(
2437 r#"
2438 dialect = "postgresql"
2439 driver = "aws-data-api"
2440 [dbCredentials]
2441 database = "mydb"
2442 secretArn = "arn:aws:secretsmanager:us-east-1:123:secret:db-xyz"
2443 resourceArn = "arn:aws:rds:us-east-1:123:cluster:my-aurora"
2444 "#,
2445 Path::new("test.toml"),
2446 )
2447 .unwrap();
2448
2449 let db = cfg.default_database().unwrap();
2450 assert_eq!(db.driver, Some(Driver::AwsDataApi));
2451 match db.credentials().unwrap() {
2452 Some(Credentials::AwsDataApi {
2453 database,
2454 secret_arn,
2455 resource_arn,
2456 }) => {
2457 assert_eq!(&*database, "mydb");
2458 assert!(secret_arn.starts_with("arn:aws:secretsmanager"));
2459 assert!(resource_arn.starts_with("arn:aws:rds"));
2460 }
2461 other => panic!("expected Credentials::AwsDataApi, got {other:?}"),
2462 }
2463 }
2464
2465 #[test]
2466 fn aws_data_api_credentials_resolve_from_env() {
2467 unsafe {
2468 std::env::set_var("TEST_AWS_DB", "envdb");
2469 std::env::set_var("TEST_AWS_SECRET", "arn:env:secret");
2470 std::env::set_var("TEST_AWS_RESOURCE", "arn:env:resource");
2471 }
2472 let cfg = Config::load_from_str(
2473 r#"
2474 dialect = "postgresql"
2475 driver = "aws-data-api"
2476 [dbCredentials]
2477 database = { env = "TEST_AWS_DB" }
2478 secretArn = { env = "TEST_AWS_SECRET" }
2479 resourceArn = { env = "TEST_AWS_RESOURCE" }
2480 "#,
2481 Path::new("test.toml"),
2482 )
2483 .unwrap();
2484
2485 match cfg.default_database().unwrap().credentials().unwrap() {
2486 Some(Credentials::AwsDataApi {
2487 database,
2488 secret_arn,
2489 resource_arn,
2490 }) => {
2491 assert_eq!(&*database, "envdb");
2492 assert_eq!(&*secret_arn, "arn:env:secret");
2493 assert_eq!(&*resource_arn, "arn:env:resource");
2494 }
2495 other => panic!("expected Credentials::AwsDataApi, got {other:?}"),
2496 }
2497 }
2498
2499 #[test]
2500 fn aws_data_api_requires_postgres_dialect() {
2501 let err = Config::load_from_str(
2502 r#"
2503 dialect = "sqlite"
2504 [dbCredentials]
2505 database = "mydb"
2506 secretArn = "arn:aws:secretsmanager:..."
2507 resourceArn = "arn:aws:rds:..."
2508 "#,
2509 Path::new("test.toml"),
2510 )
2511 .unwrap_err();
2512 assert!(
2513 err.to_string().contains("AWS Data API dbCredentials"),
2514 "expected AWS-specific error, got: {err}"
2515 );
2516 }
2517
2518 #[test]
2519 fn aws_data_api_requires_aws_data_api_driver() {
2520 let err = Config::load_from_str(
2522 r#"
2523 dialect = "postgresql"
2524 driver = "tokio-postgres"
2525 [dbCredentials]
2526 database = "mydb"
2527 secretArn = "arn:aws:secretsmanager:..."
2528 resourceArn = "arn:aws:rds:..."
2529 "#,
2530 Path::new("test.toml"),
2531 )
2532 .unwrap_err();
2533 assert!(
2534 err.to_string().contains("driver = \"aws-data-api\""),
2535 "expected aws-data-api driver error, got: {err}"
2536 );
2537 }
2538
2539 #[test]
2540 fn aws_data_api_driver_requires_aws_credentials() {
2541 let err = Config::load_from_str(
2543 r#"
2544 dialect = "postgresql"
2545 driver = "aws-data-api"
2546 [dbCredentials]
2547 url = "postgres://localhost/db"
2548 "#,
2549 Path::new("test.toml"),
2550 )
2551 .unwrap_err();
2552 assert!(
2553 err.to_string()
2554 .contains("database, secretArn, and resourceArn"),
2555 "expected aws-data-api creds-shape error, got: {err}"
2556 );
2557 }
2558
2559 #[test]
2560 fn aws_data_api_rejected_for_non_postgres_dialect() {
2561 let err = Config::load_from_str(
2562 r#"
2563 dialect = "sqlite"
2564 driver = "aws-data-api"
2565 "#,
2566 Path::new("test.toml"),
2567 )
2568 .unwrap_err();
2569 assert!(
2570 err.to_string().contains("invalid for sqlite"),
2571 "expected dialect/driver mismatch error, got: {err}"
2572 );
2573 }
2574
2575 #[test]
2576 fn driver_valid_for_postgres_includes_aws_data_api() {
2577 let drivers = Driver::valid_for(Dialect::Postgresql);
2578 assert!(drivers.contains(&Driver::PostgresSync));
2579 assert!(drivers.contains(&Driver::TokioPostgres));
2580 assert!(drivers.contains(&Driver::AwsDataApi));
2581 assert!(!Driver::AwsDataApi.is_valid_for(Dialect::Sqlite));
2583 assert!(!Driver::AwsDataApi.is_valid_for(Dialect::Turso));
2584 }
2585
2586 #[test]
2587 fn credentials_debug_redacts_secrets() {
2588 let postgres = Credentials::Postgres(PostgresCreds::Host {
2589 host: "localhost".into(),
2590 port: 5432,
2591 user: Some("alice".into()),
2592 password: Some("super-secret-password".into()),
2593 database: "app".into(),
2594 ssl: PostgresSslMode::Require,
2595 });
2596 let turso = Credentials::Turso {
2597 url: "libsql://example.turso.io".into(),
2598 auth_token: Some("super-secret-token".into()),
2599 };
2600 let d1 = Credentials::D1 {
2601 account_id: "account".into(),
2602 database_id: "database".into(),
2603 token: "super-secret-d1-token".into(),
2604 };
2605 let postgres_url = Credentials::Postgres(PostgresCreds::Url(
2606 "postgres://alice:super-secret-url-password@localhost/app".into(),
2607 ));
2608
2609 let rendered = format!("{postgres:?} {turso:?} {d1:?} {postgres_url:?}");
2610 assert!(rendered.contains("[REDACTED]"));
2611 assert!(!rendered.contains("super-secret"));
2612 }
2613
2614 #[cfg(windows)]
2615 #[test]
2616 fn schema_files_accept_backslash_paths() {
2617 let tmp = TempDir::new().unwrap();
2618 let cfg_dir = tmp.path().join("cfg");
2619 fs::create_dir_all(&cfg_dir).unwrap();
2620
2621 let schema_path = cfg_dir.join("src").join("schema.rs");
2622 fs::create_dir_all(schema_path.parent().unwrap()).unwrap();
2623 fs::write(&schema_path, "#[allow(dead_code)]\npub struct X;").unwrap();
2624
2625 let schema_str = schema_path.to_string_lossy().replace('/', "\\");
2627 let schema_toml = schema_str.replace('\\', "\\\\");
2629 let cfg_path = cfg_dir.join("drizzle.config.toml");
2630 let cfg = Config::load_from_str(
2631 &format!(
2632 r#"
2633 dialect = "sqlite"
2634 schema = "{}"
2635 "#,
2636 schema_toml
2637 ),
2638 &cfg_path,
2639 )
2640 .unwrap();
2641
2642 let db = cfg.default_database().unwrap();
2643 let files = db.schema_files().unwrap();
2644 assert_eq!(files, vec![schema_path]);
2645 }
2646
2647 #[test]
2651 fn schema_resolution_rejects_any_missing_path_or_empty_pattern() {
2652 let tmp = TempDir::new().unwrap();
2653 let present = tmp.path().join("present.rs");
2654 fs::write(&present, "pub struct Present;").unwrap();
2655 let present = present.to_string_lossy().replace('\\', "/");
2656 let missing = tmp
2657 .path()
2658 .join("missing.rs")
2659 .to_string_lossy()
2660 .replace('\\', "/");
2661 let empty_glob = format!(
2662 "{}/nothing/*.rs",
2663 tmp.path().to_string_lossy().replace('\\', "/")
2664 );
2665
2666 assert_eq!(
2667 resolve_schema_patterns([present.as_str()]).unwrap().len(),
2668 1
2669 );
2670 assert!(matches!(
2671 resolve_schema_patterns([present.as_str(), missing.as_str()]),
2672 Err(Error::SchemaPathNotFound(path)) if path == missing
2673 ));
2674 assert!(matches!(
2675 resolve_schema_patterns([present.as_str(), empty_glob.as_str()]),
2676 Err(Error::SchemaPatternMatchedNothing(pattern)) if pattern == empty_glob
2677 ));
2678 assert!(matches!(
2679 resolve_schema_patterns(["src/[.rs"]),
2680 Err(Error::Glob(..))
2681 ));
2682 }
2683}