1pub use drizzle_types::{Casing, ConfigValue, ConfigValueError};
8use schemars::JsonSchema;
9use serde::Deserialize;
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12
13pub const CONFIG_FILE: &str = "drizzle.config.toml";
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Deserialize, JsonSchema)]
18pub enum IntrospectCasing {
19 #[default]
21 #[serde(rename = "camel")]
22 Camel,
23 #[serde(rename = "preserve")]
25 Preserve,
26}
27
28impl IntrospectCasing {
29 #[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)]
174pub struct EntitiesFilter {
175 #[serde(default)]
177 pub roles: RolesFilter,
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, JsonSchema)]
186#[serde(rename_all = "lowercase")]
187pub enum Extension {
188 Postgis,
190}
191
192impl Extension {
193 #[must_use]
195 pub const fn as_str(self) -> &'static str {
196 match self {
197 Self::Postgis => "postgis",
198 }
199 }
200}
201
202impl std::fmt::Display for Extension {
203 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204 f.write_str(self.as_str())
205 }
206}
207
208#[derive(
217 Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, Deserialize, JsonSchema,
218)]
219#[serde(rename_all = "lowercase")]
220pub enum Dialect {
221 #[default]
223 Sqlite,
224 #[serde(alias = "postgres")]
226 Postgresql,
227 Mysql,
229 Turso,
231}
232
233impl Dialect {
234 pub const ALL: &'static [&'static str] = &["sqlite", "postgresql", "mysql", "turso"];
236
237 #[inline]
239 #[must_use]
240 pub const fn as_str(self) -> &'static str {
241 match self {
242 Self::Sqlite => "sqlite",
243 Self::Postgresql => "postgresql",
244 Self::Mysql => "mysql",
245 Self::Turso => "turso",
246 }
247 }
248
249 #[inline]
251 #[must_use]
252 pub const fn to_base(self) -> drizzle_types::Dialect {
253 match self {
254 Self::Sqlite | Self::Turso => drizzle_types::Dialect::SQLite,
255 Self::Postgresql => drizzle_types::Dialect::PostgreSQL,
256 Self::Mysql => drizzle_types::Dialect::MySQL,
257 }
258 }
259}
260
261impl std::fmt::Display for Dialect {
262 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263 f.write_str(self.as_str())
264 }
265}
266
267impl std::str::FromStr for Dialect {
268 type Err = String;
269
270 fn from_str(s: &str) -> Result<Self, Self::Err> {
271 match s {
272 "sqlite" => Ok(Self::Sqlite),
273 "postgresql" | "postgres" => Ok(Self::Postgresql),
274 "mysql" => Ok(Self::Mysql),
275 "turso" => Ok(Self::Turso),
276 _ => Err(format!(
277 "invalid dialect '{}', expected one of: {}",
278 s,
279 Self::ALL.join(", ")
280 )),
281 }
282 }
283}
284
285impl From<Dialect> for drizzle_types::Dialect {
286 #[inline]
287 fn from(d: Dialect) -> Self {
288 d.to_base()
289 }
290}
291
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, JsonSchema)]
298#[serde(rename_all = "kebab-case")]
299pub enum Driver {
300 Rusqlite,
302 Libsql,
304 Turso,
306 PostgresSync,
308 TokioPostgres,
310 MysqlSync,
312 MysqlAsync,
314 D1Http,
322 DurableSqlite,
330 AwsDataApi,
344}
345
346impl Driver {
347 pub const ALL: &'static [&'static str] = &[
349 "rusqlite",
350 "libsql",
351 "turso",
352 "postgres-sync",
353 "tokio-postgres",
354 "mysql-sync",
355 "mysql-async",
356 "d1-http",
357 "durable-sqlite",
358 "aws-data-api",
359 ];
360
361 #[inline]
363 #[must_use]
364 pub const fn as_str(self) -> &'static str {
365 match self {
366 Self::Rusqlite => "rusqlite",
367 Self::Libsql => "libsql",
368 Self::Turso => "turso",
369 Self::PostgresSync => "postgres-sync",
370 Self::TokioPostgres => "tokio-postgres",
371 Self::MysqlSync => "mysql-sync",
372 Self::MysqlAsync => "mysql-async",
373 Self::D1Http => "d1-http",
374 Self::DurableSqlite => "durable-sqlite",
375 Self::AwsDataApi => "aws-data-api",
376 }
377 }
378
379 #[must_use]
381 pub const fn valid_for(dialect: Dialect) -> &'static [Self] {
382 match dialect {
383 Dialect::Sqlite => &[Self::Rusqlite, Self::D1Http, Self::DurableSqlite],
387 Dialect::Turso => &[Self::Libsql, Self::Turso],
388 Dialect::Postgresql => &[Self::PostgresSync, Self::TokioPostgres, Self::AwsDataApi],
389 Dialect::Mysql => &[Self::MysqlSync, Self::MysqlAsync],
390 }
391 }
392
393 #[inline]
395 #[must_use]
396 pub const fn is_valid_for(self, dialect: Dialect) -> bool {
397 matches!(
398 (self, dialect),
399 (
400 Self::Rusqlite | Self::D1Http | Self::DurableSqlite,
401 Dialect::Sqlite
402 ) | (Self::Libsql | Self::Turso, Dialect::Turso)
403 | (
404 Self::PostgresSync | Self::TokioPostgres | Self::AwsDataApi,
405 Dialect::Postgresql
406 )
407 | (Self::MysqlSync | Self::MysqlAsync, Dialect::Mysql)
408 )
409 }
410
411 #[inline]
415 #[must_use]
416 pub const fn is_codegen_only(self) -> bool {
417 matches!(self, Self::DurableSqlite)
418 }
419}
420
421impl std::fmt::Display for Driver {
422 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
423 f.write_str(self.as_str())
424 }
425}
426
427impl std::str::FromStr for Driver {
428 type Err = String;
429
430 fn from_str(s: &str) -> Result<Self, Self::Err> {
431 match s {
432 "rusqlite" => Ok(Self::Rusqlite),
433 "libsql" => Ok(Self::Libsql),
434 "turso" => Ok(Self::Turso),
435 "postgres-sync" => Ok(Self::PostgresSync),
436 "tokio-postgres" => Ok(Self::TokioPostgres),
437 "mysql-sync" => Ok(Self::MysqlSync),
438 "mysql-async" => Ok(Self::MysqlAsync),
439 "d1-http" => Ok(Self::D1Http),
440 "durable-sqlite" => Ok(Self::DurableSqlite),
441 "aws-data-api" => Ok(Self::AwsDataApi),
442 _ => Err(format!(
443 "invalid driver '{}', expected one of: {}",
444 s,
445 Self::ALL.join(", ")
446 )),
447 }
448 }
449}
450
451impl std::str::FromStr for Extension {
452 type Err = String;
453
454 fn from_str(s: &str) -> Result<Self, Self::Err> {
455 match s {
456 "postgis" => Ok(Self::Postgis),
457 _ => Err(format!(
458 "invalid extension filter '{s}', expected 'postgis'"
459 )),
460 }
461 }
462}
463
464#[derive(Clone)]
472pub enum Credentials {
473 Sqlite { path: Box<str> },
475
476 Turso {
478 url: Box<str>,
479 auth_token: Option<Box<str>>,
480 },
481
482 Postgres(PostgresCreds),
484
485 MySQL(MySQLCreds),
487
488 D1 {
494 account_id: Box<str>,
495 database_id: Box<str>,
496 token: Box<str>,
497 },
498
499 AwsDataApi {
505 database: Box<str>,
506 secret_arn: Box<str>,
507 resource_arn: Box<str>,
508 },
509}
510
511#[derive(Clone)]
513pub enum PostgresCreds {
514 Url(Box<str>),
516 Host {
518 host: Box<str>,
519 port: u16,
520 user: Option<Box<str>>,
521 password: Option<Box<str>>,
522 database: Box<str>,
523 ssl: PostgresSslMode,
524 },
525}
526
527#[derive(Clone)]
530pub enum MySQLCreds {
531 Url(Box<str>),
533 Host {
535 host: Box<str>,
536 port: u16,
537 user: Option<Box<str>>,
538 password: Option<Box<str>>,
539 database: Box<str>,
540 ssl: MySQLSslMode,
541 },
542}
543
544impl std::fmt::Debug for Credentials {
545 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
546 match self {
547 Self::Sqlite { path } => f.debug_struct("Sqlite").field("path", path).finish(),
548 Self::Turso { url, auth_token } => f
549 .debug_struct("Turso")
550 .field("url", url)
551 .field("auth_token", &auth_token.as_ref().map(|_| "[REDACTED]"))
552 .finish(),
553 Self::Postgres(credentials) => f.debug_tuple("Postgres").field(credentials).finish(),
554 Self::MySQL(credentials) => f.debug_tuple("MySQL").field(credentials).finish(),
555 Self::D1 {
556 account_id,
557 database_id,
558 token,
559 } => f
560 .debug_struct("D1")
561 .field("account_id", account_id)
562 .field("database_id", database_id)
563 .field("token", &(!token.is_empty()).then_some("[REDACTED]"))
564 .finish(),
565 Self::AwsDataApi {
566 database,
567 secret_arn,
568 resource_arn,
569 } => f
570 .debug_struct("AwsDataApi")
571 .field("database", database)
572 .field("secret_arn", secret_arn)
573 .field("resource_arn", resource_arn)
574 .finish(),
575 }
576 }
577}
578
579impl std::fmt::Debug for PostgresCreds {
580 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
581 match self {
582 Self::Url(_) => f.debug_tuple("Url").field(&"[REDACTED]").finish(),
583 Self::Host {
584 host,
585 port,
586 user,
587 password,
588 database,
589 ssl,
590 } => f
591 .debug_struct("Host")
592 .field("host", host)
593 .field("port", port)
594 .field("user", user)
595 .field("password", &password.as_ref().map(|_| "[REDACTED]"))
596 .field("database", database)
597 .field("ssl", ssl)
598 .finish(),
599 }
600 }
601}
602
603impl std::fmt::Debug for MySQLCreds {
604 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
605 match self {
606 Self::Url(_) => f.debug_tuple("Url").field(&"[REDACTED]").finish(),
607 Self::Host {
608 host,
609 port,
610 user,
611 password,
612 database,
613 ssl,
614 } => f
615 .debug_struct("Host")
616 .field("host", host)
617 .field("port", port)
618 .field("user", user)
619 .field("password", &password.as_ref().map(|_| "[REDACTED]"))
620 .field("database", database)
621 .field("ssl", ssl)
622 .finish(),
623 }
624 }
625}
626
627#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
634pub enum PostgresSslMode {
635 #[default]
637 Disable,
638 Allow,
640 Prefer,
642 Require,
644 VerifyCa,
646 VerifyFull,
648}
649
650#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
655pub enum MySQLSslMode {
656 #[default]
658 Disable,
659 Required,
661 VerifyCa,
663 VerifyIdentity,
665}
666
667impl MySQLSslMode {
668 pub(crate) fn parse(value: &str) -> Result<Self, String> {
669 match value.trim().to_ascii_lowercase().as_str() {
670 "false" | "0" | "no" | "off" | "disable" | "disabled" => Ok(Self::Disable),
671 "true" | "1" | "yes" | "on" | "require" | "required" => Ok(Self::Required),
672 "verify-ca" => Ok(Self::VerifyCa),
673 "verify-identity" | "verify-full" => Ok(Self::VerifyIdentity),
674 _ => Err(format!("invalid MySQL SSL mode `{value}`")),
675 }
676 }
677}
678
679impl PostgresSslMode {
680 pub(crate) fn parse(value: &str) -> Result<Self, String> {
681 match value.trim().to_ascii_lowercase().as_str() {
682 "false" | "0" | "no" | "off" | "disable" => Ok(Self::Disable),
683 "allow" => Ok(Self::Allow),
684 "prefer" => Ok(Self::Prefer),
685 "true" | "1" | "yes" | "on" | "require" => Ok(Self::Require),
686 "verify-ca" => Ok(Self::VerifyCa),
687 "verify-full" => Ok(Self::VerifyFull),
688 _ => Err(format!("invalid PostgreSQL SSL mode `{value}`")),
689 }
690 }
691}
692
693#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
696pub struct PostgresConnectionConfig {
697 pub config: tokio_postgres::Config,
699 pub ssl: PostgresSslMode,
701}
702
703#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
704impl PostgresCreds {
705 pub fn connection_config(&self) -> Result<PostgresConnectionConfig, String> {
712 match self {
713 Self::Url(url) => {
714 let config = url
715 .parse::<tokio_postgres::Config>()
716 .map_err(|error| error.to_string())?;
717 let ssl = match config.get_ssl_mode() {
718 tokio_postgres::config::SslMode::Disable => PostgresSslMode::Disable,
719 tokio_postgres::config::SslMode::Prefer => PostgresSslMode::Prefer,
720 tokio_postgres::config::SslMode::Require => PostgresSslMode::Require,
721 _ => PostgresSslMode::Require,
722 };
723 Ok(PostgresConnectionConfig { config, ssl })
724 }
725 Self::Host {
726 host,
727 port,
728 user,
729 password,
730 database,
731 ssl,
732 } => {
733 let mut config = tokio_postgres::Config::new();
734 config
735 .host(host.as_ref())
736 .port(*port)
737 .dbname(database.as_ref());
738 if let Some(user) = user {
739 config.user(user.as_ref());
740 }
741 if let Some(password) = password {
742 config.password(password.as_bytes());
743 }
744 config.ssl_mode(match ssl {
745 PostgresSslMode::Disable => tokio_postgres::config::SslMode::Disable,
746 PostgresSslMode::Allow | PostgresSslMode::Prefer => {
747 tokio_postgres::config::SslMode::Prefer
748 }
749 PostgresSslMode::Require
750 | PostgresSslMode::VerifyCa
751 | PostgresSslMode::VerifyFull => tokio_postgres::config::SslMode::Require,
752 });
753 Ok(PostgresConnectionConfig { config, ssl: *ssl })
754 }
755 }
756 }
757}
758
759#[derive(Debug, Clone, Deserialize, JsonSchema)]
766#[serde(untagged)]
767pub enum Schema {
768 One(String),
770 Many(Vec<String>),
772}
773
774impl Default for Schema {
775 fn default() -> Self {
776 Self::One("src/schema.rs".into())
777 }
778}
779
780impl Schema {
781 pub fn iter(&self) -> impl Iterator<Item = &str> {
783 match self {
784 Self::One(s) => std::slice::from_ref(s).iter().map(String::as_str),
785 Self::Many(v) => v.iter().map(String::as_str),
786 }
787 }
788}
789
790#[derive(Debug, Clone, Deserialize, JsonSchema)]
792#[serde(untagged)]
793pub enum Filter {
794 One(String),
796 Many(Vec<String>),
798}
799
800impl Filter {
801 pub fn iter(&self) -> impl Iterator<Item = &str> {
803 match self {
804 Self::One(s) => std::slice::from_ref(s).iter().map(String::as_str),
805 Self::Many(v) => v.iter().map(String::as_str),
806 }
807 }
808}
809
810#[derive(Debug, Clone, Deserialize, JsonSchema)]
812pub struct MigrationsOpts {
813 pub table: Option<String>,
815 pub schema: Option<String>,
817 pub prefix: Option<MigrationPrefix>,
819 #[serde(default)]
826 pub bundle: Option<bool>,
827}
828
829#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)]
831#[serde(rename_all = "lowercase")]
832pub enum MigrationPrefix {
833 Index,
835 Timestamp,
837 Supabase,
839 Unix,
841 None,
843}
844
845#[derive(Debug, Clone, Deserialize, JsonSchema)]
850#[serde(untagged)]
851enum RawCreds {
852 D1 {
859 #[serde(rename = "accountId")]
860 account_id: ConfigValue,
861 #[serde(rename = "databaseId")]
862 database_id: ConfigValue,
863 token: ConfigValue,
864 },
865 AwsDataApi {
872 database: ConfigValue,
873 #[serde(rename = "secretArn")]
874 secret_arn: ConfigValue,
875 #[serde(rename = "resourceArn")]
876 resource_arn: ConfigValue,
877 },
878 Url {
879 url: ConfigValue,
880 #[serde(default, rename = "authToken")]
881 auth_token: Option<ConfigValue>,
882 },
883 Host {
884 host: ConfigValue,
885 #[serde(default)]
886 port: Option<u16>,
887 #[serde(default)]
888 user: Option<ConfigValue>,
889 #[serde(default)]
890 password: Option<ConfigValue>,
891 database: ConfigValue,
892 #[serde(default)]
893 ssl: Option<SslVal>,
894 },
895}
896
897#[derive(Debug, Clone, Deserialize, JsonSchema)]
898#[serde(untagged)]
899enum SslVal {
900 Bool(bool),
901 Str(String),
902}
903
904impl SslVal {
905 fn postgres_mode(&self) -> Result<PostgresSslMode, String> {
906 match self {
907 Self::Bool(false) => Ok(PostgresSslMode::Disable),
908 Self::Bool(true) => Ok(PostgresSslMode::Require),
909 Self::Str(value) => PostgresSslMode::parse(value),
910 }
911 }
912
913 fn mysql_mode(&self) -> Result<MySQLSslMode, String> {
914 match self {
915 Self::Bool(false) => Ok(MySQLSslMode::Disable),
916 Self::Bool(true) => Ok(MySQLSslMode::Required),
917 Self::Str(value) => MySQLSslMode::parse(value),
918 }
919 }
920}
921
922#[derive(Debug, Clone, Deserialize, JsonSchema)]
931#[serde(rename_all = "camelCase")]
932pub struct DatabaseConfig {
933 pub dialect: Dialect,
935
936 #[serde(default)]
938 pub schema: Schema,
939
940 #[serde(default = "default_out")]
942 pub out: PathBuf,
943
944 #[serde(default = "yes")]
946 pub breakpoints: bool,
947
948 #[serde(default)]
950 pub driver: Option<Driver>,
951
952 #[serde(default)]
954 db_credentials: Option<RawCreds>,
955
956 #[serde(default)]
958 pub tables_filter: Option<Filter>,
959
960 #[serde(default)]
962 pub schema_filter: Option<Filter>,
963
964 #[serde(default)]
966 pub extensions_filters: Option<Vec<Extension>>,
967
968 #[serde(default)]
970 pub entities: Option<EntitiesFilter>,
971
972 #[serde(default)]
975 pub casing: Option<Casing>,
976
977 #[serde(default)]
979 pub introspect: Option<IntrospectConfig>,
980
981 #[serde(default)]
983 pub verbose: bool,
984
985 #[serde(default)]
987 pub migrations: Option<MigrationsOpts>,
988}
989
990fn default_out() -> PathBuf {
991 PathBuf::from("./drizzle")
992}
993
994const fn yes() -> bool {
995 true
996}
997
998impl DatabaseConfig {
999 fn normalize_paths(&mut self, base_dir: &Path) {
1000 if self.out.is_relative() {
1003 self.out = base_dir.join(&self.out);
1004 }
1005
1006 let base = base_dir.to_string_lossy().replace('\\', "/");
1010 let base = base.trim_end_matches('/').to_string();
1011
1012 let normalize_one = |p: &str| -> String {
1013 let p_trim = p.trim();
1014 let is_abs = Path::new(p_trim).is_absolute() || p_trim.starts_with("\\\\");
1015 let joined = if is_abs || base.is_empty() || base == "." {
1016 p_trim.to_string()
1017 } else {
1018 format!("{base}/{p_trim}")
1019 };
1020 joined.replace('\\', "/")
1021 };
1022
1023 match &mut self.schema {
1024 Schema::One(p) => *p = normalize_one(p),
1025 Schema::Many(v) => {
1026 for p in v.iter_mut() {
1027 *p = normalize_one(p);
1028 }
1029 }
1030 }
1031 }
1032
1033 fn validate(&self, name: &str) -> Result<(), Error> {
1034 if let Some(d) = self.driver
1036 && !d.is_valid_for(self.dialect)
1037 {
1038 return Err(Error::InvalidDriver {
1039 driver: d,
1040 dialect: self.dialect,
1041 });
1042 }
1043
1044 if let Some(ref raw) = self.db_credentials {
1046 self.validate_creds(raw, name)?;
1047 }
1048
1049 if self.dialect != Dialect::Postgresql {
1051 if self.schema_filter.is_some() {
1052 return Err(Error::InvalidConfig(
1053 "schemaFilter is only supported for dialect = \"postgresql\"".into(),
1054 ));
1055 }
1056 if self.extensions_filters.is_some() {
1057 return Err(Error::InvalidConfig(
1058 "extensionsFilters is only supported for dialect = \"postgresql\"".into(),
1059 ));
1060 }
1061 if self.entities.is_some() {
1062 return Err(Error::InvalidConfig(
1063 "entities filter is only supported for dialect = \"postgresql\"".into(),
1064 ));
1065 }
1066 }
1067
1068 Ok(())
1069 }
1070
1071 fn validate_creds(&self, raw: &RawCreds, _name: &str) -> Result<(), Error> {
1072 let err = |msg: &str| Error::InvalidCredentials(msg.into());
1073
1074 match (self.dialect, raw) {
1077 (
1078 Dialect::Postgresql | Dialect::Mysql,
1079 RawCreds::Host { .. } | RawCreds::Url { .. },
1080 ) => {}
1081 (_, RawCreds::Host { .. }) => {
1082 return Err(err(
1083 "host-based dbCredentials are only supported for dialect = \"postgresql\" or \"mysql\"",
1084 ));
1085 }
1086 _ => {}
1087 }
1088
1089 if let RawCreds::D1 { .. } = raw {
1093 if self.dialect != Dialect::Sqlite {
1094 return Err(err(
1095 "D1 dbCredentials (accountId/databaseId/token) require dialect = \"sqlite\"",
1096 ));
1097 }
1098 if self.driver != Some(Driver::D1Http) {
1099 return Err(err(
1100 "D1 dbCredentials (accountId/databaseId/token) require driver = \"d1-http\"",
1101 ));
1102 }
1103 }
1104
1105 if self.driver == Some(Driver::D1Http) && !matches!(raw, RawCreds::D1 { .. }) {
1109 return Err(err(
1110 "driver = \"d1-http\" requires dbCredentials with accountId, databaseId, and token",
1111 ));
1112 }
1113
1114 if let RawCreds::AwsDataApi { .. } = raw {
1118 if self.dialect != Dialect::Postgresql {
1119 return Err(err(
1120 "AWS Data API dbCredentials (database/secretArn/resourceArn) require dialect = \"postgresql\"",
1121 ));
1122 }
1123 if self.driver != Some(Driver::AwsDataApi) {
1124 return Err(err(
1125 "AWS Data API dbCredentials (database/secretArn/resourceArn) require driver = \"aws-data-api\"",
1126 ));
1127 }
1128 }
1129
1130 if self.driver == Some(Driver::AwsDataApi) && !matches!(raw, RawCreds::AwsDataApi { .. }) {
1132 return Err(err(
1133 "driver = \"aws-data-api\" requires dbCredentials with database, secretArn, and resourceArn",
1134 ));
1135 }
1136
1137 match (self.dialect, raw) {
1139 (
1140 Dialect::Sqlite,
1141 RawCreds::Url {
1142 auth_token: Some(_),
1143 ..
1144 },
1145 ) => Err(err(
1146 "SQLite doesn't support authToken (use dialect = \"turso\")",
1147 )),
1148 (
1149 Dialect::Mysql,
1150 RawCreds::Url {
1151 auth_token: Some(_),
1152 ..
1153 },
1154 ) => Err(err("MySQL doesn't support authToken")),
1155 (
1156 Dialect::Sqlite,
1157 RawCreds::Url {
1158 url: ConfigValue::Inline(url),
1159 ..
1160 },
1161 ) if url.starts_with("libsql://") => Err(err(
1162 "libsql:// URLs require dialect = \"turso\" (for local SQLite files, use ./path.db)",
1163 )),
1164 (
1165 Dialect::Sqlite,
1166 RawCreds::Url {
1167 url: ConfigValue::Inline(url),
1168 ..
1169 },
1170 ) if url.starts_with("http://")
1171 || url.starts_with("https://")
1172 || url.starts_with("postgres://")
1173 || url.starts_with("postgresql://") =>
1174 {
1175 Err(err(
1176 "SQLite dbCredentials.url must be a local file path (not an http(s)/postgres URL)",
1177 ))
1178 }
1179 (
1180 Dialect::Turso,
1181 RawCreds::Url {
1182 url: ConfigValue::Inline(url),
1183 ..
1184 },
1185 ) if !url.starts_with("libsql://") && !url.starts_with("http") => {
1186 Err(err("Turso URL must start with libsql:// or http(s)://"))
1187 }
1188 (
1189 Dialect::Postgresql,
1190 RawCreds::Url {
1191 url: ConfigValue::Inline(url),
1192 ..
1193 },
1194 ) if !url.starts_with("postgres") => {
1195 Err(err("PostgreSQL URL must start with postgres://"))
1196 }
1197 (Dialect::Mysql, RawCreds::Host { ssl: Some(ssl), .. }) => ssl
1198 .mysql_mode()
1199 .map(|_| ())
1200 .map_err(Error::InvalidCredentials),
1201 (
1202 Dialect::Mysql,
1203 RawCreds::Url {
1204 url: ConfigValue::Inline(url),
1205 ..
1206 },
1207 ) if !url.starts_with("mysql://") => Err(err("MySQL URL must start with mysql://")),
1208 _ => Ok(()),
1209 }
1210 }
1211
1212 pub fn credentials(&self) -> Result<Option<Credentials>, Error> {
1223 let Some(raw) = self.db_credentials.as_ref() else {
1224 return Ok(None);
1225 };
1226
1227 let resolve_opt = |opt: &Option<ConfigValue>| -> Result<Option<Box<str>>, Error> {
1229 match opt.as_ref() {
1230 None => Ok(None),
1231 Some(e) => Ok(Some(e.resolve()?.into_boxed_str())),
1232 }
1233 };
1234
1235 let creds = match (self.dialect, raw) {
1236 (
1240 Dialect::Sqlite,
1241 RawCreds::D1 {
1242 account_id,
1243 database_id,
1244 token,
1245 },
1246 ) => Credentials::D1 {
1247 account_id: account_id.resolve()?.into_boxed_str(),
1248 database_id: database_id.resolve()?.into_boxed_str(),
1249 token: token.resolve()?.into_boxed_str(),
1250 },
1251 (
1254 Dialect::Postgresql,
1255 RawCreds::AwsDataApi {
1256 database,
1257 secret_arn,
1258 resource_arn,
1259 },
1260 ) => Credentials::AwsDataApi {
1261 database: database.resolve()?.into_boxed_str(),
1262 secret_arn: secret_arn.resolve()?.into_boxed_str(),
1263 resource_arn: resource_arn.resolve()?.into_boxed_str(),
1264 },
1265 (Dialect::Sqlite, RawCreds::Url { url, .. }) => Credentials::Sqlite {
1267 path: url.resolve()?.into_boxed_str(),
1268 },
1269 (Dialect::Turso, RawCreds::Url { url, auth_token }) => Credentials::Turso {
1271 url: url.resolve()?.into_boxed_str(),
1272 auth_token: resolve_opt(auth_token)?,
1273 },
1274 (Dialect::Postgresql, RawCreds::Url { url, .. }) => {
1276 Credentials::Postgres(PostgresCreds::Url(url.resolve()?.into_boxed_str()))
1277 }
1278 (
1280 Dialect::Mysql,
1281 RawCreds::Url {
1282 url,
1283 auth_token: None,
1284 },
1285 ) => Credentials::MySQL(MySQLCreds::Url(url.resolve()?.into_boxed_str())),
1286 (
1288 Dialect::Postgresql,
1289 RawCreds::Host {
1290 host,
1291 port,
1292 user,
1293 password,
1294 database,
1295 ssl,
1296 },
1297 ) => Credentials::Postgres(PostgresCreds::Host {
1298 host: host.resolve()?.into_boxed_str(),
1299 port: port.unwrap_or(5432),
1300 user: resolve_opt(user)?,
1301 password: resolve_opt(password)?,
1302 database: database.resolve()?.into_boxed_str(),
1303 ssl: ssl
1304 .as_ref()
1305 .map_or(Ok(PostgresSslMode::Disable), SslVal::postgres_mode)
1306 .map_err(Error::InvalidCredentials)?,
1307 }),
1308 (
1310 Dialect::Mysql,
1311 RawCreds::Host {
1312 host,
1313 port,
1314 user,
1315 password,
1316 database,
1317 ssl,
1318 },
1319 ) => Credentials::MySQL(MySQLCreds::Host {
1320 host: host.resolve()?.into_boxed_str(),
1321 port: port.unwrap_or(3306),
1322 user: resolve_opt(user)?,
1323 password: resolve_opt(password)?,
1324 database: database.resolve()?.into_boxed_str(),
1325 ssl: ssl
1326 .as_ref()
1327 .map_or(Ok(MySQLSslMode::Disable), SslVal::mysql_mode)
1328 .map_err(Error::InvalidCredentials)?,
1329 }),
1330 _ => return Ok(None),
1331 };
1332
1333 Ok(Some(creds))
1334 }
1335
1336 #[inline]
1338 #[must_use]
1339 pub fn migrations_dir(&self) -> &Path {
1340 &self.out
1341 }
1342
1343 #[inline]
1345 #[must_use]
1346 pub fn meta_dir(&self) -> PathBuf {
1347 self.out.join("meta")
1348 }
1349
1350 #[inline]
1352 #[must_use]
1353 pub fn journal_path(&self) -> PathBuf {
1354 self.meta_dir().join("_journal.json")
1355 }
1356
1357 #[must_use]
1359 pub fn schema_display(&self) -> String {
1360 match &self.schema {
1361 Schema::One(s) => s.clone(),
1362 Schema::Many(v) => v.join(", "),
1363 }
1364 }
1365
1366 pub fn schema_files(&self) -> Result<Vec<PathBuf>, Error> {
1374 let files = resolve_schema_patterns(self.schema.iter())?;
1375 if files.is_empty() {
1376 return Err(Error::NoSchemaFiles(self.schema_display()));
1377 }
1378 Ok(files)
1379 }
1380
1381 #[inline]
1383 #[must_use]
1384 pub fn effective_casing(&self) -> Casing {
1385 self.casing.unwrap_or_default()
1386 }
1387
1388 #[inline]
1390 #[must_use]
1391 pub fn effective_introspect_casing(&self) -> IntrospectCasing {
1392 self.introspect
1393 .as_ref()
1394 .map(|i| i.casing)
1395 .unwrap_or_default()
1396 }
1397
1398 #[inline]
1400 #[must_use]
1401 pub fn effective_entities(&self) -> EntitiesFilter {
1402 self.entities.clone().unwrap_or_default()
1403 }
1404
1405 #[must_use]
1408 pub fn should_include_role(&self, role_name: &str) -> bool {
1409 self.entities
1410 .as_ref()
1411 .is_some_and(|e| e.roles.should_include(role_name))
1412 }
1413
1414 #[must_use]
1416 pub fn roles_enabled(&self) -> bool {
1417 self.entities.as_ref().is_some_and(|e| e.roles.is_enabled())
1418 }
1419
1420 #[must_use]
1423 pub fn extensions(&self) -> &[Extension] {
1424 self.extensions_filters.as_deref().unwrap_or(&[])
1425 }
1426
1427 #[must_use]
1429 pub fn has_extension(&self, ext: Extension) -> bool {
1430 self.extensions_filters
1431 .as_ref()
1432 .is_some_and(|v| v.contains(&ext))
1433 }
1434
1435 #[must_use]
1437 pub fn migrations_table(&self) -> &str {
1438 self.migrations
1439 .as_ref()
1440 .and_then(|m| m.table.as_deref())
1441 .unwrap_or("__drizzle_migrations")
1442 }
1443
1444 #[must_use]
1447 pub fn migrations_schema(&self) -> &str {
1448 self.migrations
1449 .as_ref()
1450 .and_then(|m| m.schema.as_deref())
1451 .unwrap_or("drizzle")
1452 }
1453
1454 #[must_use]
1463 pub fn bundle_enabled(&self) -> bool {
1464 if let Some(explicit) = self.migrations.as_ref().and_then(|m| m.bundle) {
1465 return explicit;
1466 }
1467 matches!(self.driver, Some(Driver::DurableSqlite))
1468 }
1469}
1470
1471#[derive(Debug, Clone, Deserialize)]
1477struct MultiDbConfig {
1478 databases: HashMap<String, DatabaseConfig>,
1479}
1480
1481#[derive(Debug, Clone)]
1505pub struct Config {
1506 databases: HashMap<String, DatabaseConfig>,
1508 is_single: bool,
1510}
1511
1512pub const DEFAULT_DB: &str = "default";
1514
1515impl Config {
1516 pub fn load() -> Result<Self, Error> {
1523 Self::load_from(Path::new(CONFIG_FILE))
1524 }
1525
1526 pub fn load_from(path: &Path) -> Result<Self, Error> {
1561 let content = std::fs::read_to_string(path).map_err(|e| {
1562 if e.kind() == std::io::ErrorKind::NotFound {
1563 Error::NotFound(path.into())
1564 } else {
1565 Error::Io(path.into(), e)
1566 }
1567 })?;
1568
1569 Self::load_from_str(&content, path)
1570 }
1571
1572 fn load_from_str(content: &str, path: &Path) -> Result<Self, Error> {
1574 let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
1575
1576 if let Ok(multi) = toml::from_str::<MultiDbConfig>(content)
1578 && !multi.databases.is_empty()
1579 {
1580 let mut config = Self {
1581 databases: multi.databases,
1582 is_single: false,
1583 };
1584 for db in config.databases.values_mut() {
1585 db.normalize_paths(base_dir);
1586 }
1587 config.validate()?;
1588 return Ok(config);
1589 }
1590
1591 let db_config: DatabaseConfig =
1593 toml::from_str(content).map_err(|e| Error::Parse(path.into(), e))?;
1594
1595 let mut databases = HashMap::new();
1596 databases.insert(DEFAULT_DB.to_string(), db_config);
1597
1598 let mut config = Self {
1599 databases,
1600 is_single: true,
1601 };
1602 for db in config.databases.values_mut() {
1603 db.normalize_paths(base_dir);
1604 }
1605 config.validate()?;
1606 Ok(config)
1607 }
1608
1609 fn validate(&self) -> Result<(), Error> {
1610 for (name, db) in &self.databases {
1611 db.validate(name)?;
1612 }
1613 Ok(())
1614 }
1615
1616 #[must_use]
1618 pub const fn is_single_database(&self) -> bool {
1619 self.is_single
1620 }
1621
1622 pub fn database_names(&self) -> impl Iterator<Item = &str> {
1625 self.databases.keys().map(String::as_str)
1626 }
1627
1628 pub fn database(&self, name: Option<&str>) -> Result<&DatabaseConfig, Error> {
1641 name.map_or_else(
1642 || {
1643 if self.is_single {
1645 self.databases.get(DEFAULT_DB).ok_or(Error::NoDatabases)
1646 } else if self.databases.len() == 1 {
1647 self.databases.values().next().ok_or(Error::NoDatabases)
1648 } else {
1649 Err(Error::DatabaseRequired(
1650 self.databases.keys().cloned().collect(),
1651 ))
1652 }
1653 },
1654 |name| {
1655 if self.is_single {
1656 self.databases.get(DEFAULT_DB).ok_or(Error::NoDatabases)
1658 } else {
1659 self.databases
1660 .get(name)
1661 .ok_or_else(|| Error::DatabaseNotFound(name.to_string()))
1662 }
1663 },
1664 )
1665 }
1666
1667 pub fn default_database(&self) -> Result<&DatabaseConfig, Error> {
1673 self.database(None)
1674 }
1675
1676 #[must_use]
1682 pub fn dialect(&self) -> Dialect {
1683 self.default_database()
1684 .map(|d| d.dialect)
1685 .unwrap_or_default()
1686 }
1687
1688 pub fn credentials(&self) -> Result<Option<Credentials>, Error> {
1695 self.default_database()?.credentials()
1696 }
1697
1698 #[must_use]
1701 pub fn migrations_dir(&self) -> &Path {
1702 self.default_database()
1703 .map_or_else(|_| Path::new("./drizzle"), |d| d.migrations_dir())
1704 }
1705
1706 #[must_use]
1709 pub fn journal_path(&self) -> PathBuf {
1710 self.default_database().map_or_else(
1711 |_| PathBuf::from("./drizzle/meta/_journal.json"),
1712 DatabaseConfig::journal_path,
1713 )
1714 }
1715
1716 #[must_use]
1719 pub fn schema_display(&self) -> String {
1720 self.default_database()
1721 .map_or_else(|_| "src/schema.rs".into(), DatabaseConfig::schema_display)
1722 }
1723
1724 pub fn schema_files(&self) -> Result<Vec<PathBuf>, Error> {
1732 self.default_database()?.schema_files()
1733 }
1734
1735 #[must_use]
1738 pub fn base_dialect(&self) -> drizzle_types::Dialect {
1739 self.dialect().to_base()
1740 }
1741}
1742
1743pub fn resolve_schema_patterns<'a>(
1757 patterns: impl IntoIterator<Item = &'a str>,
1758) -> Result<Vec<PathBuf>, Error> {
1759 let mut files = Vec::new();
1760
1761 for pattern in patterns {
1762 let pat = pattern.trim();
1763 let is_glob = pat.contains('*') || pat.contains('?') || pat.contains('[');
1764
1765 if !is_glob {
1766 let path = PathBuf::from(pat);
1768 let normalized = PathBuf::from(pat.replace('\\', "/"));
1769 if path.is_file() {
1770 files.push(path);
1771 } else if normalized.is_file() {
1772 files.push(normalized);
1773 } else {
1774 return Err(Error::SchemaPathNotFound(pat.into()));
1775 }
1776 continue;
1777 }
1778
1779 let pat_norm = pat.replace('\\', "/");
1781 let paths = glob::glob(&pat_norm).map_err(|e| Error::Glob(pat.into(), e))?;
1782 let mut matched = false;
1783 for entry in paths {
1784 let path = entry.map_err(|e| Error::GlobRead(pat.into(), e))?;
1785 if path.is_file() {
1787 files.push(path);
1788 matched = true;
1789 }
1790 }
1791 if !matched {
1792 return Err(Error::SchemaPatternMatchedNothing(pat.into()));
1793 }
1794 }
1795
1796 files.sort();
1797 files.dedup();
1798 Ok(files)
1799}
1800
1801#[derive(Debug, thiserror::Error)]
1807pub enum Error {
1808 #[error("config not found: {}", .0.display())]
1810 NotFound(PathBuf),
1811
1812 #[error("failed to read {}: {}", .0.display(), .1)]
1814 Io(PathBuf, #[source] std::io::Error),
1815
1816 #[error("failed to parse {}: {}", .0.display(), .1)]
1818 Parse(PathBuf, #[source] toml::de::Error),
1819
1820 #[error("driver '{driver}' invalid for {dialect} dialect")]
1822 InvalidDriver {
1823 driver: Driver,
1825 dialect: Dialect,
1827 },
1828
1829 #[error("invalid credentials: {0}")]
1831 InvalidCredentials(String),
1832
1833 #[error("invalid config: {0}")]
1835 InvalidConfig(String),
1836
1837 #[error("invalid glob '{0}': {1}")]
1839 Glob(String, #[source] glob::PatternError),
1840
1841 #[error("failed to read a path matched by glob '{0}': {1}")]
1843 GlobRead(String, #[source] glob::GlobError),
1844
1845 #[error("schema path '{0}' does not exist")]
1847 SchemaPathNotFound(String),
1848
1849 #[error("schema pattern '{0}' matches no file")]
1851 SchemaPatternMatchedNothing(String),
1852
1853 #[error("no schema files found: {0}")]
1855 NoSchemaFiles(String),
1856
1857 #[error("environment variable '{0}' not found")]
1859 EnvNotFound(String),
1860
1861 #[error("environment variable '{0}' invalid: {1}")]
1863 EnvInvalid(String, String),
1864
1865 #[error("no databases configured")]
1867 NoDatabases,
1868
1869 #[error("database '{0}' not found")]
1871 DatabaseNotFound(String),
1872
1873 #[error("multiple databases configured, use --db to specify: {}", .0.join(", "))]
1875 DatabaseRequired(Vec<String>),
1876}
1877
1878impl From<ConfigValueError> for Error {
1879 fn from(err: ConfigValueError) -> Self {
1880 match err {
1881 ConfigValueError::NotPresent(var) => Self::EnvNotFound(var),
1882 ConfigValueError::NotUnicode(var) => {
1883 Self::EnvInvalid(var, "contains invalid unicode".into())
1884 }
1885 }
1886 }
1887}
1888
1889#[cfg(test)]
1894mod tests {
1895 use super::*;
1896 use std::fs;
1897 use tempfile::TempDir;
1898
1899 #[test]
1900 fn sqlite() {
1901 let cfg = Config::load_from_str(
1902 r#"
1903 dialect = "sqlite"
1904 [dbCredentials]
1905 url = "./dev.db"
1906 "#,
1907 Path::new("test.toml"),
1908 )
1909 .unwrap();
1910 assert!(cfg.is_single_database());
1911 assert!(matches!(
1912 cfg.credentials().unwrap(),
1913 Some(Credentials::Sqlite { .. })
1914 ));
1915 }
1916
1917 #[test]
1918 fn postgres_url() {
1919 let cfg = Config::load_from_str(
1920 r#"
1921 dialect = "postgresql"
1922 [dbCredentials]
1923 url = "postgres://localhost/db"
1924 "#,
1925 Path::new("test.toml"),
1926 )
1927 .unwrap();
1928 assert!(matches!(
1929 cfg.credentials().unwrap(),
1930 Some(Credentials::Postgres(PostgresCreds::Url(_)))
1931 ));
1932 }
1933
1934 #[test]
1935 fn mysql_url_credentials_and_driver_parse() {
1936 let cfg = Config::load_from_str(
1937 r#"
1938 dialect = "mysql"
1939 driver = "mysql-async"
1940 [dbCredentials]
1941 url = "mysql://user:secret@localhost:3306/app"
1942 "#,
1943 Path::new("test.toml"),
1944 )
1945 .unwrap();
1946 let db = cfg.default_database().unwrap();
1947 assert_eq!(db.dialect, Dialect::Mysql);
1948 assert_eq!(db.driver, Some(Driver::MysqlAsync));
1949 assert!(matches!(
1950 db.credentials().unwrap(),
1951 Some(Credentials::MySQL(MySQLCreds::Url(_)))
1952 ));
1953 }
1954
1955 #[test]
1956 fn mysql_host_credentials_use_mysql_defaults() {
1957 let cfg = Config::load_from_str(
1958 r#"
1959 dialect = "mysql"
1960 driver = "mysql-sync"
1961 [dbCredentials]
1962 host = "db.example.com"
1963 user = "app"
1964 password = "secret"
1965 database = "app_db"
1966 ssl = "verify-identity"
1967 "#,
1968 Path::new("test.toml"),
1969 )
1970 .unwrap();
1971 match cfg.default_database().unwrap().credentials().unwrap() {
1972 Some(Credentials::MySQL(MySQLCreds::Host {
1973 port,
1974 ssl,
1975 database,
1976 ..
1977 })) => {
1978 assert_eq!(port, 3306);
1979 assert_eq!(ssl, MySQLSslMode::VerifyIdentity);
1980 assert_eq!(database.as_ref(), "app_db");
1981 }
1982 other => panic!("unexpected credentials: {other:?}"),
1983 }
1984 }
1985
1986 #[test]
1987 fn mysql_host_credentials_reject_unrepresentable_preferred_tls() {
1988 let error = Config::load_from_str(
1989 r#"
1990 dialect = "mysql"
1991 [dbCredentials]
1992 host = "db.example.com"
1993 database = "app_db"
1994 ssl = "preferred"
1995 "#,
1996 Path::new("test.toml"),
1997 )
1998 .expect_err("host credentials cannot express opportunistic TLS");
1999
2000 assert!(
2001 error.to_string().contains("invalid MySQL SSL mode"),
2002 "{error}"
2003 );
2004 }
2005
2006 #[test]
2007 fn mysql_rejects_cross_dialect_driver_and_url() {
2008 let driver_error = Config::load_from_str(
2009 r#"
2010 dialect = "mysql"
2011 driver = "postgres-sync"
2012 [dbCredentials]
2013 url = "mysql://localhost/app"
2014 "#,
2015 Path::new("test.toml"),
2016 )
2017 .expect_err("postgres driver must not target mysql");
2018 assert!(driver_error.to_string().contains("invalid for mysql"));
2019
2020 let url_error = Config::load_from_str(
2021 r#"
2022 dialect = "mysql"
2023 [dbCredentials]
2024 url = "postgres://localhost/app"
2025 "#,
2026 Path::new("test.toml"),
2027 )
2028 .expect_err("postgres URL must not target mysql");
2029 assert!(url_error.to_string().contains("must start with mysql://"));
2030 }
2031
2032 #[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
2033 #[test]
2034 fn postgres_host_credentials_are_structured_without_url_encoding() {
2035 let credentials = PostgresCreds::Host {
2036 host: "db.example.com".into(),
2037 port: 5432,
2038 user: Some("user@tenant".into()),
2039 password: Some("p:a/ss% word".into()),
2040 database: "app/db name".into(),
2041 ssl: PostgresSslMode::VerifyFull,
2042 };
2043
2044 let connection = credentials.connection_config().expect("build config");
2045 assert_eq!(connection.config.get_user(), Some("user@tenant"));
2046 assert_eq!(connection.config.get_password(), Some(&b"p:a/ss% word"[..]));
2047 assert_eq!(connection.config.get_dbname(), Some("app/db name"));
2048 assert_eq!(
2049 connection.config.get_ssl_mode(),
2050 tokio_postgres::config::SslMode::Require
2051 );
2052 assert_eq!(connection.ssl, PostgresSslMode::VerifyFull);
2053 }
2054
2055 #[test]
2056 fn multi_database() {
2057 let cfg = Config::load_from_str(
2058 r#"
2059 [databases.dev]
2060 dialect = "sqlite"
2061 out = "./drizzle/sqlite"
2062 [databases.dev.dbCredentials]
2063 url = "./dev.db"
2064
2065 [databases.prod]
2066 dialect = "postgresql"
2067 out = "./drizzle/postgres"
2068 [databases.prod.dbCredentials]
2069 url = "postgres://localhost/db"
2070 "#,
2071 Path::new("test.toml"),
2072 )
2073 .unwrap();
2074
2075 assert!(!cfg.is_single_database());
2076 let names: Vec<_> = cfg.database_names().collect();
2077 assert!(names.contains(&"dev"));
2078 assert!(names.contains(&"prod"));
2079
2080 let dev = cfg.database(Some("dev")).unwrap();
2081 assert_eq!(dev.dialect, Dialect::Sqlite);
2082
2083 let prod = cfg.database(Some("prod")).unwrap();
2084 assert_eq!(prod.dialect, Dialect::Postgresql);
2085 }
2086
2087 #[test]
2088 fn multi_database_requires_selection() {
2089 let cfg = Config::load_from_str(
2090 r#"
2091 [databases.a]
2092 dialect = "sqlite"
2093 [databases.b]
2094 dialect = "postgresql"
2095 "#,
2096 Path::new("test.toml"),
2097 )
2098 .unwrap();
2099
2100 assert!(cfg.database(None).is_err());
2102 }
2103
2104 #[test]
2105 fn env_var_syntax() {
2106 let cfg = Config::load_from_str(
2107 r#"
2108 dialect = "postgresql"
2109 [dbCredentials]
2110 url = { env = "DATABASE_URL" }
2111 "#,
2112 Path::new("test.toml"),
2113 )
2114 .unwrap();
2115 assert!(cfg.is_single_database());
2116 }
2117
2118 #[test]
2119 fn casing_options() {
2120 let cfg = Config::load_from_str(
2121 r#"
2122 dialect = "postgresql"
2123 casing = "snake_case"
2124 [dbCredentials]
2125 url = "postgres://localhost/db"
2126 "#,
2127 Path::new("test.toml"),
2128 )
2129 .unwrap();
2130 let db = cfg.default_database().unwrap();
2131 assert_eq!(db.effective_casing(), Casing::SnakeCase);
2132
2133 let cfg2 = Config::load_from_str(
2135 r#"
2136 dialect = "postgresql"
2137 [dbCredentials]
2138 url = "postgres://localhost/db"
2139 "#,
2140 Path::new("test.toml"),
2141 )
2142 .unwrap();
2143 let db2 = cfg2.default_database().unwrap();
2144 assert_eq!(db2.effective_casing(), Casing::CamelCase);
2145 }
2146
2147 #[test]
2148 fn introspect_casing() {
2149 let cfg = Config::load_from_str(
2150 r#"
2151 dialect = "postgresql"
2152 [introspect]
2153 casing = "preserve"
2154 [dbCredentials]
2155 url = "postgres://localhost/db"
2156 "#,
2157 Path::new("test.toml"),
2158 )
2159 .unwrap();
2160 let db = cfg.default_database().unwrap();
2161 assert_eq!(db.effective_introspect_casing(), IntrospectCasing::Preserve);
2162 }
2163
2164 #[test]
2165 fn entities_roles_filter() {
2166 let cfg = Config::load_from_str(
2168 r#"
2169 dialect = "postgresql"
2170 [entities]
2171 roles = true
2172 [dbCredentials]
2173 url = "postgres://localhost/db"
2174 "#,
2175 Path::new("test.toml"),
2176 )
2177 .unwrap();
2178 let db = cfg.default_database().unwrap();
2179 assert!(db.roles_enabled());
2180 assert!(db.should_include_role("my_role"));
2181
2182 let cfg2 = Config::load_from_str(
2184 r#"
2185 dialect = "postgresql"
2186 [entities.roles]
2187 provider = "supabase"
2188 [dbCredentials]
2189 url = "postgres://localhost/db"
2190 "#,
2191 Path::new("test.toml"),
2192 )
2193 .unwrap();
2194 let db2 = cfg2.default_database().unwrap();
2195 assert!(db2.roles_enabled());
2196 assert!(!db2.should_include_role("anon")); assert!(db2.should_include_role("my_custom_role"));
2198 }
2199
2200 #[test]
2201 fn extensions_filter() {
2202 let cfg = Config::load_from_str(
2203 r#"
2204 dialect = "postgresql"
2205 extensionsFilters = ["postgis"]
2206 [dbCredentials]
2207 url = "postgres://localhost/db"
2208 "#,
2209 Path::new("test.toml"),
2210 )
2211 .unwrap();
2212 let db = cfg.default_database().unwrap();
2213 assert!(db.has_extension(Extension::Postgis));
2214 }
2215
2216 #[test]
2217 fn rejects_postgres_only_filters_for_sqlite() {
2218 let err = Config::load_from_str(
2219 r#"
2220 dialect = "sqlite"
2221 schemaFilter = ["public"]
2222 [dbCredentials]
2223 url = "./dev.db"
2224 "#,
2225 Path::new("test.toml"),
2226 )
2227 .expect_err("sqlite should reject schemaFilter");
2228 assert_eq!(
2229 err.to_string(),
2230 "invalid config: schemaFilter is only supported for dialect = \"postgresql\""
2231 );
2232
2233 let err = Config::load_from_str(
2234 r#"
2235 dialect = "sqlite"
2236 extensionsFilters = ["postgis"]
2237 [dbCredentials]
2238 url = "./dev.db"
2239 "#,
2240 Path::new("test.toml"),
2241 )
2242 .expect_err("sqlite should reject extensionsFilters");
2243 assert_eq!(
2244 err.to_string(),
2245 "invalid config: extensionsFilters is only supported for dialect = \"postgresql\""
2246 );
2247 }
2248
2249 #[test]
2250 fn rejects_entities_filter_for_turso() {
2251 let err = Config::load_from_str(
2252 r#"
2253 dialect = "turso"
2254 [entities]
2255 roles = true
2256 [dbCredentials]
2257 url = "libsql://example.turso.io"
2258 "#,
2259 Path::new("test.toml"),
2260 )
2261 .expect_err("turso should reject entities filter");
2262 assert_eq!(
2263 err.to_string(),
2264 "invalid config: entities filter is only supported for dialect = \"postgresql\""
2265 );
2266 }
2267
2268 #[test]
2269 fn migrations_config() {
2270 let cfg = Config::load_from_str(
2271 r#"
2272 dialect = "postgresql"
2273 [migrations]
2274 table = "custom_migrations"
2275 schema = "custom_schema"
2276 [dbCredentials]
2277 url = "postgres://localhost/db"
2278 "#,
2279 Path::new("test.toml"),
2280 )
2281 .unwrap();
2282 let db = cfg.default_database().unwrap();
2283 assert_eq!(db.migrations_table(), "custom_migrations");
2284 assert_eq!(db.migrations_schema(), "custom_schema");
2285
2286 let cfg2 = Config::load_from_str(
2288 r#"
2289 dialect = "postgresql"
2290 [dbCredentials]
2291 url = "postgres://localhost/db"
2292 "#,
2293 Path::new("test.toml"),
2294 )
2295 .unwrap();
2296 let db2 = cfg2.default_database().unwrap();
2297 assert_eq!(db2.migrations_table(), "__drizzle_migrations");
2298 assert_eq!(db2.migrations_schema(), "drizzle");
2299 }
2300
2301 #[test]
2302 fn resolves_paths_relative_to_config_dir() {
2303 let tmp = TempDir::new().unwrap();
2304 let cfg_dir = tmp.path().join("cfg");
2305 fs::create_dir_all(&cfg_dir).unwrap();
2306
2307 let schema_path = cfg_dir.join("schema.rs");
2309 fs::write(&schema_path, "#[allow(dead_code)]\npub struct X;").unwrap();
2310
2311 let cfg_path = cfg_dir.join("drizzle.config.toml");
2312 let cfg = Config::load_from_str(
2313 r#"
2314 dialect = "sqlite"
2315 schema = "schema.rs"
2316 out = "./drizzle"
2317 [dbCredentials]
2318 url = "./dev.db"
2319 "#,
2320 &cfg_path,
2321 )
2322 .unwrap();
2323
2324 let db = cfg.default_database().unwrap();
2325 assert_eq!(db.migrations_dir(), cfg_dir.join("./drizzle").as_path());
2326
2327 let files = db.schema_files().unwrap();
2328 assert_eq!(files.len(), 1);
2329 assert_eq!(files[0], schema_path);
2330 }
2331
2332 #[test]
2333 fn rejects_host_credentials_for_sqlite() {
2334 let err = Config::load_from_str(
2335 r#"
2336 dialect = "sqlite"
2337 [dbCredentials]
2338 host = "localhost"
2339 database = "db"
2340 "#,
2341 Path::new("test.toml"),
2342 )
2343 .unwrap_err();
2344
2345 assert_eq!(
2346 err.to_string(),
2347 "invalid credentials: host-based dbCredentials are only supported for dialect = \"postgresql\" or \"mysql\""
2348 );
2349 }
2350
2351 #[test]
2356 fn d1_http_credentials_parse() {
2357 let cfg = Config::load_from_str(
2358 r#"
2359 dialect = "sqlite"
2360 driver = "d1-http"
2361 [dbCredentials]
2362 accountId = "acc_abc"
2363 databaseId = "db_xyz"
2364 token = "tok_123"
2365 "#,
2366 Path::new("test.toml"),
2367 )
2368 .unwrap();
2369
2370 let db = cfg.default_database().unwrap();
2371 assert_eq!(db.driver, Some(Driver::D1Http));
2372 match db.credentials().unwrap() {
2373 Some(Credentials::D1 {
2374 account_id,
2375 database_id,
2376 token,
2377 }) => {
2378 assert_eq!(&*account_id, "acc_abc");
2379 assert_eq!(&*database_id, "db_xyz");
2380 assert_eq!(&*token, "tok_123");
2381 }
2382 other => panic!("expected Credentials::D1, got {other:?}"),
2383 }
2384 }
2385
2386 #[test]
2387 fn d1_http_credentials_resolve_from_env() {
2388 unsafe {
2390 std::env::set_var("TEST_D1_ACCT", "env_acct");
2391 std::env::set_var("TEST_D1_DB", "env_db");
2392 std::env::set_var("TEST_D1_TOKEN", "env_token");
2393 }
2394 let cfg = Config::load_from_str(
2395 r#"
2396 dialect = "sqlite"
2397 driver = "d1-http"
2398 [dbCredentials]
2399 accountId = { env = "TEST_D1_ACCT" }
2400 databaseId = { env = "TEST_D1_DB" }
2401 token = { env = "TEST_D1_TOKEN" }
2402 "#,
2403 Path::new("test.toml"),
2404 )
2405 .unwrap();
2406
2407 match cfg.default_database().unwrap().credentials().unwrap() {
2408 Some(Credentials::D1 {
2409 account_id,
2410 database_id,
2411 token,
2412 }) => {
2413 assert_eq!(&*account_id, "env_acct");
2414 assert_eq!(&*database_id, "env_db");
2415 assert_eq!(&*token, "env_token");
2416 }
2417 other => panic!("expected Credentials::D1, got {other:?}"),
2418 }
2419 }
2420
2421 #[test]
2422 fn d1_credentials_require_sqlite_dialect() {
2423 let err = Config::load_from_str(
2424 r#"
2425 dialect = "postgresql"
2426 [dbCredentials]
2427 accountId = "acc"
2428 databaseId = "db"
2429 token = "tok"
2430 "#,
2431 Path::new("test.toml"),
2432 )
2433 .unwrap_err();
2434 assert!(
2435 err.to_string().contains("D1 dbCredentials"),
2436 "expected D1-specific error, got: {err}"
2437 );
2438 }
2439
2440 #[test]
2441 fn d1_credentials_require_d1_http_driver() {
2442 let err = Config::load_from_str(
2444 r#"
2445 dialect = "sqlite"
2446 driver = "rusqlite"
2447 [dbCredentials]
2448 accountId = "acc"
2449 databaseId = "db"
2450 token = "tok"
2451 "#,
2452 Path::new("test.toml"),
2453 )
2454 .unwrap_err();
2455 assert!(
2456 err.to_string().contains("driver = \"d1-http\""),
2457 "expected d1-http driver error, got: {err}"
2458 );
2459 }
2460
2461 #[test]
2462 fn d1_http_driver_requires_d1_credentials() {
2463 let err = Config::load_from_str(
2465 r#"
2466 dialect = "sqlite"
2467 driver = "d1-http"
2468 [dbCredentials]
2469 url = "./dev.db"
2470 "#,
2471 Path::new("test.toml"),
2472 )
2473 .unwrap_err();
2474 assert!(
2475 err.to_string().contains("accountId, databaseId, and token"),
2476 "expected d1-http creds-shape error, got: {err}"
2477 );
2478 }
2479
2480 #[test]
2481 fn durable_sqlite_no_credentials_ok() {
2482 let cfg = Config::load_from_str(
2485 r#"
2486 dialect = "sqlite"
2487 driver = "durable-sqlite"
2488 "#,
2489 Path::new("test.toml"),
2490 )
2491 .unwrap();
2492
2493 let db = cfg.default_database().unwrap();
2494 assert_eq!(db.driver, Some(Driver::DurableSqlite));
2495 assert!(db.credentials().unwrap().is_none());
2496 assert!(
2498 db.bundle_enabled(),
2499 "durable-sqlite should auto-enable bundle"
2500 );
2501 }
2502
2503 #[test]
2504 fn durable_sqlite_explicit_bundle_false_respected() {
2505 let cfg = Config::load_from_str(
2507 r#"
2508 dialect = "sqlite"
2509 driver = "durable-sqlite"
2510 [migrations]
2511 bundle = false
2512 "#,
2513 Path::new("test.toml"),
2514 )
2515 .unwrap();
2516 assert!(!cfg.default_database().unwrap().bundle_enabled());
2517 }
2518
2519 #[test]
2520 fn durable_sqlite_rejects_non_sqlite_dialect() {
2521 let err = Config::load_from_str(
2522 r#"
2523 dialect = "postgresql"
2524 driver = "durable-sqlite"
2525 [dbCredentials]
2526 url = "postgres://localhost/db"
2527 "#,
2528 Path::new("test.toml"),
2529 )
2530 .unwrap_err();
2531 assert!(
2532 err.to_string().contains("invalid for postgresql"),
2533 "expected dialect/driver mismatch error, got: {err}"
2534 );
2535 }
2536
2537 #[test]
2538 fn driver_valid_for_sqlite_includes_cloudflare() {
2539 let drivers = Driver::valid_for(Dialect::Sqlite);
2540 assert!(drivers.contains(&Driver::Rusqlite));
2541 assert!(drivers.contains(&Driver::D1Http));
2542 assert!(drivers.contains(&Driver::DurableSqlite));
2543 for drv in [Driver::D1Http, Driver::DurableSqlite] {
2545 assert!(!drv.is_valid_for(Dialect::Postgresql));
2546 assert!(!drv.is_valid_for(Dialect::Turso));
2547 }
2548 }
2549
2550 #[test]
2551 fn driver_is_codegen_only_flag() {
2552 assert!(Driver::DurableSqlite.is_codegen_only());
2553 assert!(!Driver::D1Http.is_codegen_only());
2554 assert!(!Driver::Rusqlite.is_codegen_only());
2555 assert!(!Driver::AwsDataApi.is_codegen_only());
2556 }
2557
2558 #[test]
2563 fn aws_data_api_credentials_parse() {
2564 let cfg = Config::load_from_str(
2565 r#"
2566 dialect = "postgresql"
2567 driver = "aws-data-api"
2568 [dbCredentials]
2569 database = "mydb"
2570 secretArn = "arn:aws:secretsmanager:us-east-1:123:secret:db-xyz"
2571 resourceArn = "arn:aws:rds:us-east-1:123:cluster:my-aurora"
2572 "#,
2573 Path::new("test.toml"),
2574 )
2575 .unwrap();
2576
2577 let db = cfg.default_database().unwrap();
2578 assert_eq!(db.driver, Some(Driver::AwsDataApi));
2579 match db.credentials().unwrap() {
2580 Some(Credentials::AwsDataApi {
2581 database,
2582 secret_arn,
2583 resource_arn,
2584 }) => {
2585 assert_eq!(&*database, "mydb");
2586 assert!(secret_arn.starts_with("arn:aws:secretsmanager"));
2587 assert!(resource_arn.starts_with("arn:aws:rds"));
2588 }
2589 other => panic!("expected Credentials::AwsDataApi, got {other:?}"),
2590 }
2591 }
2592
2593 #[test]
2594 fn aws_data_api_credentials_resolve_from_env() {
2595 unsafe {
2596 std::env::set_var("TEST_AWS_DB", "envdb");
2597 std::env::set_var("TEST_AWS_SECRET", "arn:env:secret");
2598 std::env::set_var("TEST_AWS_RESOURCE", "arn:env:resource");
2599 }
2600 let cfg = Config::load_from_str(
2601 r#"
2602 dialect = "postgresql"
2603 driver = "aws-data-api"
2604 [dbCredentials]
2605 database = { env = "TEST_AWS_DB" }
2606 secretArn = { env = "TEST_AWS_SECRET" }
2607 resourceArn = { env = "TEST_AWS_RESOURCE" }
2608 "#,
2609 Path::new("test.toml"),
2610 )
2611 .unwrap();
2612
2613 match cfg.default_database().unwrap().credentials().unwrap() {
2614 Some(Credentials::AwsDataApi {
2615 database,
2616 secret_arn,
2617 resource_arn,
2618 }) => {
2619 assert_eq!(&*database, "envdb");
2620 assert_eq!(&*secret_arn, "arn:env:secret");
2621 assert_eq!(&*resource_arn, "arn:env:resource");
2622 }
2623 other => panic!("expected Credentials::AwsDataApi, got {other:?}"),
2624 }
2625 }
2626
2627 #[test]
2628 fn aws_data_api_requires_postgres_dialect() {
2629 let err = Config::load_from_str(
2630 r#"
2631 dialect = "sqlite"
2632 [dbCredentials]
2633 database = "mydb"
2634 secretArn = "arn:aws:secretsmanager:..."
2635 resourceArn = "arn:aws:rds:..."
2636 "#,
2637 Path::new("test.toml"),
2638 )
2639 .unwrap_err();
2640 assert!(
2641 err.to_string().contains("AWS Data API dbCredentials"),
2642 "expected AWS-specific error, got: {err}"
2643 );
2644 }
2645
2646 #[test]
2647 fn aws_data_api_requires_aws_data_api_driver() {
2648 let err = Config::load_from_str(
2650 r#"
2651 dialect = "postgresql"
2652 driver = "tokio-postgres"
2653 [dbCredentials]
2654 database = "mydb"
2655 secretArn = "arn:aws:secretsmanager:..."
2656 resourceArn = "arn:aws:rds:..."
2657 "#,
2658 Path::new("test.toml"),
2659 )
2660 .unwrap_err();
2661 assert!(
2662 err.to_string().contains("driver = \"aws-data-api\""),
2663 "expected aws-data-api driver error, got: {err}"
2664 );
2665 }
2666
2667 #[test]
2668 fn aws_data_api_driver_requires_aws_credentials() {
2669 let err = Config::load_from_str(
2671 r#"
2672 dialect = "postgresql"
2673 driver = "aws-data-api"
2674 [dbCredentials]
2675 url = "postgres://localhost/db"
2676 "#,
2677 Path::new("test.toml"),
2678 )
2679 .unwrap_err();
2680 assert!(
2681 err.to_string()
2682 .contains("database, secretArn, and resourceArn"),
2683 "expected aws-data-api creds-shape error, got: {err}"
2684 );
2685 }
2686
2687 #[test]
2688 fn aws_data_api_rejected_for_non_postgres_dialect() {
2689 let err = Config::load_from_str(
2690 r#"
2691 dialect = "sqlite"
2692 driver = "aws-data-api"
2693 "#,
2694 Path::new("test.toml"),
2695 )
2696 .unwrap_err();
2697 assert!(
2698 err.to_string().contains("invalid for sqlite"),
2699 "expected dialect/driver mismatch error, got: {err}"
2700 );
2701 }
2702
2703 #[test]
2704 fn driver_valid_for_postgres_includes_aws_data_api() {
2705 let drivers = Driver::valid_for(Dialect::Postgresql);
2706 assert!(drivers.contains(&Driver::PostgresSync));
2707 assert!(drivers.contains(&Driver::TokioPostgres));
2708 assert!(drivers.contains(&Driver::AwsDataApi));
2709 assert!(!Driver::AwsDataApi.is_valid_for(Dialect::Sqlite));
2711 assert!(!Driver::AwsDataApi.is_valid_for(Dialect::Turso));
2712 }
2713
2714 #[test]
2715 fn credentials_debug_redacts_secrets() {
2716 let postgres = Credentials::Postgres(PostgresCreds::Host {
2717 host: "localhost".into(),
2718 port: 5432,
2719 user: Some("alice".into()),
2720 password: Some("super-secret-password".into()),
2721 database: "app".into(),
2722 ssl: PostgresSslMode::Require,
2723 });
2724 let turso = Credentials::Turso {
2725 url: "libsql://example.turso.io".into(),
2726 auth_token: Some("super-secret-token".into()),
2727 };
2728 let d1 = Credentials::D1 {
2729 account_id: "account".into(),
2730 database_id: "database".into(),
2731 token: "super-secret-d1-token".into(),
2732 };
2733 let postgres_url = Credentials::Postgres(PostgresCreds::Url(
2734 "postgres://alice:super-secret-url-password@localhost/app".into(),
2735 ));
2736
2737 let rendered = format!("{postgres:?} {turso:?} {d1:?} {postgres_url:?}");
2738 assert!(rendered.contains("[REDACTED]"));
2739 assert!(!rendered.contains("super-secret"));
2740 }
2741
2742 #[cfg(windows)]
2743 #[test]
2744 fn schema_files_accept_backslash_paths() {
2745 let tmp = TempDir::new().unwrap();
2746 let cfg_dir = tmp.path().join("cfg");
2747 fs::create_dir_all(&cfg_dir).unwrap();
2748
2749 let schema_path = cfg_dir.join("src").join("schema.rs");
2750 fs::create_dir_all(schema_path.parent().unwrap()).unwrap();
2751 fs::write(&schema_path, "#[allow(dead_code)]\npub struct X;").unwrap();
2752
2753 let schema_str = schema_path.to_string_lossy().replace('/', "\\");
2755 let schema_toml = schema_str.replace('\\', "\\\\");
2757 let cfg_path = cfg_dir.join("drizzle.config.toml");
2758 let cfg = Config::load_from_str(
2759 &format!(
2760 r#"
2761 dialect = "sqlite"
2762 schema = "{}"
2763 "#,
2764 schema_toml
2765 ),
2766 &cfg_path,
2767 )
2768 .unwrap();
2769
2770 let db = cfg.default_database().unwrap();
2771 let files = db.schema_files().unwrap();
2772 assert_eq!(files, vec![schema_path]);
2773 }
2774
2775 #[test]
2779 fn schema_resolution_rejects_any_missing_path_or_empty_pattern() {
2780 let tmp = TempDir::new().unwrap();
2781 let present = tmp.path().join("present.rs");
2782 fs::write(&present, "pub struct Present;").unwrap();
2783 let present = present.to_string_lossy().replace('\\', "/");
2784 let missing = tmp
2785 .path()
2786 .join("missing.rs")
2787 .to_string_lossy()
2788 .replace('\\', "/");
2789 let empty_glob = format!(
2790 "{}/nothing/*.rs",
2791 tmp.path().to_string_lossy().replace('\\', "/")
2792 );
2793
2794 assert_eq!(
2795 resolve_schema_patterns([present.as_str()]).unwrap().len(),
2796 1
2797 );
2798 assert!(matches!(
2799 resolve_schema_patterns([present.as_str(), missing.as_str()]),
2800 Err(Error::SchemaPathNotFound(path)) if path == missing
2801 ));
2802 assert!(matches!(
2803 resolve_schema_patterns([present.as_str(), empty_glob.as_str()]),
2804 Err(Error::SchemaPatternMatchedNothing(pattern)) if pattern == empty_glob
2805 ));
2806 assert!(matches!(
2807 resolve_schema_patterns(["src/[.rs"]),
2808 Err(Error::Glob(..))
2809 ));
2810 }
2811}