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!(
167 role_name,
168 "neon_superuser"
169 | "cloud_admin"
170 | "zenith_admin"
171 | "none"
172 | "neon_service"
173 | "authenticated"
174 | "anonymous"
175 ),
176 _ => false,
177 }
178}
179
180#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
182pub struct EntitiesFilter {
183 #[serde(default)]
185 pub roles: RolesFilter,
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, JsonSchema)]
194#[serde(rename_all = "lowercase")]
195pub enum Extension {
196 Postgis,
198}
199
200impl Extension {
201 #[must_use]
203 pub const fn as_str(self) -> &'static str {
204 match self {
205 Self::Postgis => "postgis",
206 }
207 }
208}
209
210impl std::fmt::Display for Extension {
211 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212 f.write_str(self.as_str())
213 }
214}
215
216#[derive(
225 Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, Deserialize, JsonSchema,
226)]
227#[serde(rename_all = "lowercase")]
228pub enum Dialect {
229 #[default]
231 Sqlite,
232 #[serde(alias = "postgres")]
234 Postgresql,
235 Mysql,
237 Turso,
239}
240
241impl Dialect {
242 pub const ALL: &'static [&'static str] = &["sqlite", "postgresql", "mysql", "turso"];
244
245 #[inline]
247 #[must_use]
248 pub const fn as_str(self) -> &'static str {
249 match self {
250 Self::Sqlite => "sqlite",
251 Self::Postgresql => "postgresql",
252 Self::Mysql => "mysql",
253 Self::Turso => "turso",
254 }
255 }
256
257 #[inline]
259 #[must_use]
260 pub const fn to_base(self) -> drizzle_types::Dialect {
261 match self {
262 Self::Sqlite | Self::Turso => drizzle_types::Dialect::SQLite,
263 Self::Postgresql => drizzle_types::Dialect::PostgreSQL,
264 Self::Mysql => drizzle_types::Dialect::MySQL,
265 }
266 }
267}
268
269impl std::fmt::Display for Dialect {
270 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
271 f.write_str(self.as_str())
272 }
273}
274
275impl std::str::FromStr for Dialect {
276 type Err = String;
277
278 fn from_str(s: &str) -> Result<Self, Self::Err> {
279 match s {
280 "sqlite" => Ok(Self::Sqlite),
281 "postgresql" | "postgres" => Ok(Self::Postgresql),
282 "mysql" => Ok(Self::Mysql),
283 "turso" => Ok(Self::Turso),
284 _ => Err(format!(
285 "invalid dialect '{}', expected one of: {}",
286 s,
287 Self::ALL.join(", ")
288 )),
289 }
290 }
291}
292
293impl From<Dialect> for drizzle_types::Dialect {
294 #[inline]
295 fn from(d: Dialect) -> Self {
296 d.to_base()
297 }
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, JsonSchema)]
306#[serde(rename_all = "kebab-case")]
307pub enum Driver {
308 Rusqlite,
310 Libsql,
312 Turso,
314 PostgresSync,
316 TokioPostgres,
318 MysqlSync,
320 MysqlAsync,
322 D1Http,
330 DurableSqlite,
338 AwsDataApi,
352}
353
354impl Driver {
355 pub const ALL: &'static [&'static str] = &[
357 "rusqlite",
358 "libsql",
359 "turso",
360 "postgres-sync",
361 "tokio-postgres",
362 "mysql-sync",
363 "mysql-async",
364 "d1-http",
365 "durable-sqlite",
366 "aws-data-api",
367 ];
368
369 #[inline]
371 #[must_use]
372 pub const fn as_str(self) -> &'static str {
373 match self {
374 Self::Rusqlite => "rusqlite",
375 Self::Libsql => "libsql",
376 Self::Turso => "turso",
377 Self::PostgresSync => "postgres-sync",
378 Self::TokioPostgres => "tokio-postgres",
379 Self::MysqlSync => "mysql-sync",
380 Self::MysqlAsync => "mysql-async",
381 Self::D1Http => "d1-http",
382 Self::DurableSqlite => "durable-sqlite",
383 Self::AwsDataApi => "aws-data-api",
384 }
385 }
386
387 #[must_use]
389 pub const fn valid_for(dialect: Dialect) -> &'static [Self] {
390 match dialect {
391 Dialect::Sqlite => &[Self::Rusqlite, Self::D1Http, Self::DurableSqlite],
395 Dialect::Turso => &[Self::Libsql, Self::Turso],
396 Dialect::Postgresql => &[Self::PostgresSync, Self::TokioPostgres, Self::AwsDataApi],
397 Dialect::Mysql => &[Self::MysqlSync, Self::MysqlAsync],
398 }
399 }
400
401 #[inline]
403 #[must_use]
404 pub const fn is_valid_for(self, dialect: Dialect) -> bool {
405 matches!(
406 (self, dialect),
407 (
408 Self::Rusqlite | Self::D1Http | Self::DurableSqlite,
409 Dialect::Sqlite
410 ) | (Self::Libsql | Self::Turso, Dialect::Turso)
411 | (
412 Self::PostgresSync | Self::TokioPostgres | Self::AwsDataApi,
413 Dialect::Postgresql
414 )
415 | (Self::MysqlSync | Self::MysqlAsync, Dialect::Mysql)
416 )
417 }
418
419 #[inline]
423 #[must_use]
424 pub const fn is_codegen_only(self) -> bool {
425 matches!(self, Self::DurableSqlite)
426 }
427}
428
429impl std::fmt::Display for Driver {
430 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
431 f.write_str(self.as_str())
432 }
433}
434
435impl std::str::FromStr for Driver {
436 type Err = String;
437
438 fn from_str(s: &str) -> Result<Self, Self::Err> {
439 match s {
440 "rusqlite" => Ok(Self::Rusqlite),
441 "libsql" => Ok(Self::Libsql),
442 "turso" => Ok(Self::Turso),
443 "postgres-sync" => Ok(Self::PostgresSync),
444 "tokio-postgres" => Ok(Self::TokioPostgres),
445 "mysql-sync" => Ok(Self::MysqlSync),
446 "mysql-async" => Ok(Self::MysqlAsync),
447 "d1-http" => Ok(Self::D1Http),
448 "durable-sqlite" => Ok(Self::DurableSqlite),
449 "aws-data-api" => Ok(Self::AwsDataApi),
450 _ => Err(format!(
451 "invalid driver '{}', expected one of: {}",
452 s,
453 Self::ALL.join(", ")
454 )),
455 }
456 }
457}
458
459impl std::str::FromStr for Extension {
460 type Err = String;
461
462 fn from_str(s: &str) -> Result<Self, Self::Err> {
463 match s {
464 "postgis" => Ok(Self::Postgis),
465 _ => Err(format!(
466 "invalid extension filter '{s}', expected 'postgis'"
467 )),
468 }
469 }
470}
471
472#[derive(Clone)]
480pub enum Credentials {
481 Sqlite { path: Box<str> },
483
484 Turso {
486 url: Box<str>,
487 auth_token: Option<Box<str>>,
488 },
489
490 Postgres(PostgresCreds),
492
493 MySQL(MySQLCreds),
495
496 D1 {
502 account_id: Box<str>,
503 database_id: Box<str>,
504 token: Box<str>,
505 },
506
507 AwsDataApi {
513 database: Box<str>,
514 secret_arn: Box<str>,
515 resource_arn: Box<str>,
516 },
517}
518
519#[derive(Clone)]
521pub enum PostgresCreds {
522 Url(Box<str>),
524 Host {
526 host: Box<str>,
527 port: u16,
528 user: Option<Box<str>>,
529 password: Option<Box<str>>,
530 database: Box<str>,
531 ssl: PostgresSslMode,
532 },
533}
534
535#[derive(Clone)]
538pub enum MySQLCreds {
539 Url(Box<str>),
541 Host {
543 host: Box<str>,
544 port: u16,
545 user: Option<Box<str>>,
546 password: Option<Box<str>>,
547 database: Box<str>,
548 ssl: MySQLSslMode,
549 },
550}
551
552impl std::fmt::Debug for Credentials {
553 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
554 match self {
555 Self::Sqlite { path } => f.debug_struct("Sqlite").field("path", path).finish(),
556 Self::Turso { url, auth_token } => f
557 .debug_struct("Turso")
558 .field("url", url)
559 .field("auth_token", &auth_token.as_ref().map(|_| "[REDACTED]"))
560 .finish(),
561 Self::Postgres(credentials) => f.debug_tuple("Postgres").field(credentials).finish(),
562 Self::MySQL(credentials) => f.debug_tuple("MySQL").field(credentials).finish(),
563 Self::D1 {
564 account_id,
565 database_id,
566 token,
567 } => f
568 .debug_struct("D1")
569 .field("account_id", account_id)
570 .field("database_id", database_id)
571 .field("token", &(!token.is_empty()).then_some("[REDACTED]"))
572 .finish(),
573 Self::AwsDataApi {
574 database,
575 secret_arn,
576 resource_arn,
577 } => f
578 .debug_struct("AwsDataApi")
579 .field("database", database)
580 .field("secret_arn", secret_arn)
581 .field("resource_arn", resource_arn)
582 .finish(),
583 }
584 }
585}
586
587impl std::fmt::Debug for PostgresCreds {
588 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
589 match self {
590 Self::Url(_) => f.debug_tuple("Url").field(&"[REDACTED]").finish(),
591 Self::Host {
592 host,
593 port,
594 user,
595 password,
596 database,
597 ssl,
598 } => f
599 .debug_struct("Host")
600 .field("host", host)
601 .field("port", port)
602 .field("user", user)
603 .field("password", &password.as_ref().map(|_| "[REDACTED]"))
604 .field("database", database)
605 .field("ssl", ssl)
606 .finish(),
607 }
608 }
609}
610
611impl std::fmt::Debug for MySQLCreds {
612 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
613 match self {
614 Self::Url(_) => f.debug_tuple("Url").field(&"[REDACTED]").finish(),
615 Self::Host {
616 host,
617 port,
618 user,
619 password,
620 database,
621 ssl,
622 } => f
623 .debug_struct("Host")
624 .field("host", host)
625 .field("port", port)
626 .field("user", user)
627 .field("password", &password.as_ref().map(|_| "[REDACTED]"))
628 .field("database", database)
629 .field("ssl", ssl)
630 .finish(),
631 }
632 }
633}
634
635#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
642pub enum PostgresSslMode {
643 #[default]
645 Disable,
646 Allow,
648 Prefer,
650 Require,
652 VerifyCa,
654 VerifyFull,
656}
657
658#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
663pub enum MySQLSslMode {
664 #[default]
666 Disable,
667 Required,
669 VerifyCa,
671 VerifyIdentity,
673}
674
675impl MySQLSslMode {
676 pub(crate) fn parse(value: &str) -> Result<Self, String> {
677 match value.trim().to_ascii_lowercase().as_str() {
678 "false" | "0" | "no" | "off" | "disable" | "disabled" => Ok(Self::Disable),
679 "true" | "1" | "yes" | "on" | "require" | "required" => Ok(Self::Required),
680 "verify-ca" => Ok(Self::VerifyCa),
681 "verify-identity" | "verify-full" => Ok(Self::VerifyIdentity),
682 _ => Err(format!("invalid MySQL SSL mode `{value}`")),
683 }
684 }
685}
686
687impl PostgresSslMode {
688 pub(crate) fn parse(value: &str) -> Result<Self, String> {
689 match value.trim().to_ascii_lowercase().as_str() {
690 "false" | "0" | "no" | "off" | "disable" => Ok(Self::Disable),
691 "allow" => Ok(Self::Allow),
692 "prefer" => Ok(Self::Prefer),
693 "true" | "1" | "yes" | "on" | "require" => Ok(Self::Require),
694 "verify-ca" => Ok(Self::VerifyCa),
695 "verify-full" => Ok(Self::VerifyFull),
696 _ => Err(format!("invalid PostgreSQL SSL mode `{value}`")),
697 }
698 }
699}
700
701#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
704pub struct PostgresConnectionConfig {
705 pub config: tokio_postgres::Config,
707 pub ssl: PostgresSslMode,
709}
710
711#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
712impl PostgresCreds {
713 pub fn connection_config(&self) -> Result<PostgresConnectionConfig, String> {
720 match self {
721 Self::Url(url) => {
722 let config = url
723 .parse::<tokio_postgres::Config>()
724 .map_err(|error| error.to_string())?;
725 let ssl = match config.get_ssl_mode() {
726 tokio_postgres::config::SslMode::Disable => PostgresSslMode::Disable,
727 tokio_postgres::config::SslMode::Prefer => PostgresSslMode::Prefer,
728 tokio_postgres::config::SslMode::Require => PostgresSslMode::Require,
729 _ => PostgresSslMode::Require,
730 };
731 Ok(PostgresConnectionConfig { config, ssl })
732 }
733 Self::Host {
734 host,
735 port,
736 user,
737 password,
738 database,
739 ssl,
740 } => {
741 let mut config = tokio_postgres::Config::new();
742 config
743 .host(host.as_ref())
744 .port(*port)
745 .dbname(database.as_ref());
746 if let Some(user) = user {
747 config.user(user.as_ref());
748 }
749 if let Some(password) = password {
750 config.password(password.as_bytes());
751 }
752 config.ssl_mode(match ssl {
753 PostgresSslMode::Disable => tokio_postgres::config::SslMode::Disable,
754 PostgresSslMode::Allow | PostgresSslMode::Prefer => {
755 tokio_postgres::config::SslMode::Prefer
756 }
757 PostgresSslMode::Require
758 | PostgresSslMode::VerifyCa
759 | PostgresSslMode::VerifyFull => tokio_postgres::config::SslMode::Require,
760 });
761 Ok(PostgresConnectionConfig { config, ssl: *ssl })
762 }
763 }
764 }
765}
766
767#[derive(Debug, Clone, Deserialize, JsonSchema)]
774#[serde(untagged)]
775pub enum Schema {
776 One(String),
778 Many(Vec<String>),
780}
781
782impl Default for Schema {
783 fn default() -> Self {
784 Self::One("src/schema.rs".into())
785 }
786}
787
788impl Schema {
789 pub fn iter(&self) -> impl Iterator<Item = &str> {
791 match self {
792 Self::One(s) => std::slice::from_ref(s).iter().map(String::as_str),
793 Self::Many(v) => v.iter().map(String::as_str),
794 }
795 }
796}
797
798#[derive(Debug, Clone, Deserialize, JsonSchema)]
800#[serde(untagged)]
801pub enum Filter {
802 One(String),
804 Many(Vec<String>),
806}
807
808impl Filter {
809 pub fn iter(&self) -> impl Iterator<Item = &str> {
811 match self {
812 Self::One(s) => std::slice::from_ref(s).iter().map(String::as_str),
813 Self::Many(v) => v.iter().map(String::as_str),
814 }
815 }
816}
817
818#[derive(Debug, Clone, Deserialize, JsonSchema)]
820pub struct MigrationsOpts {
821 pub table: Option<String>,
823 pub schema: Option<String>,
825 pub prefix: Option<MigrationPrefix>,
827 #[serde(default)]
834 pub bundle: Option<bool>,
835}
836
837#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)]
839#[serde(rename_all = "lowercase")]
840pub enum MigrationPrefix {
841 Index,
843 Timestamp,
845 Supabase,
847 Unix,
849 None,
851}
852
853#[derive(Debug, Clone, Deserialize, JsonSchema)]
858#[serde(untagged)]
859enum RawCreds {
860 D1 {
867 #[serde(rename = "accountId")]
868 account_id: ConfigValue,
869 #[serde(rename = "databaseId")]
870 database_id: ConfigValue,
871 token: ConfigValue,
872 },
873 AwsDataApi {
880 database: ConfigValue,
881 #[serde(rename = "secretArn")]
882 secret_arn: ConfigValue,
883 #[serde(rename = "resourceArn")]
884 resource_arn: ConfigValue,
885 },
886 Url {
887 url: ConfigValue,
888 #[serde(default, rename = "authToken")]
889 auth_token: Option<ConfigValue>,
890 },
891 Host {
892 host: ConfigValue,
893 #[serde(default)]
894 port: Option<u16>,
895 #[serde(default)]
896 user: Option<ConfigValue>,
897 #[serde(default)]
898 password: Option<ConfigValue>,
899 database: ConfigValue,
900 #[serde(default)]
901 ssl: Option<SslVal>,
902 },
903}
904
905#[derive(Debug, Clone, Deserialize, JsonSchema)]
906#[serde(untagged)]
907enum SslVal {
908 Bool(bool),
909 Str(String),
910}
911
912impl SslVal {
913 fn postgres_mode(&self) -> Result<PostgresSslMode, String> {
914 match self {
915 Self::Bool(false) => Ok(PostgresSslMode::Disable),
916 Self::Bool(true) => Ok(PostgresSslMode::Require),
917 Self::Str(value) => PostgresSslMode::parse(value),
918 }
919 }
920
921 fn mysql_mode(&self) -> Result<MySQLSslMode, String> {
922 match self {
923 Self::Bool(false) => Ok(MySQLSslMode::Disable),
924 Self::Bool(true) => Ok(MySQLSslMode::Required),
925 Self::Str(value) => MySQLSslMode::parse(value),
926 }
927 }
928}
929
930#[derive(Debug, Clone, Deserialize, JsonSchema)]
939#[serde(rename_all = "camelCase")]
940pub struct DatabaseConfig {
941 pub dialect: Dialect,
943
944 #[serde(default)]
946 pub schema: Schema,
947
948 #[serde(default = "default_out")]
950 pub out: PathBuf,
951
952 #[serde(default = "yes")]
954 pub breakpoints: bool,
955
956 #[serde(default)]
958 pub driver: Option<Driver>,
959
960 #[serde(default)]
962 db_credentials: Option<RawCreds>,
963
964 #[serde(default)]
966 pub tables_filter: Option<Filter>,
967
968 #[serde(default)]
970 pub schema_filter: Option<Filter>,
971
972 #[serde(default)]
974 pub extensions_filters: Option<Vec<Extension>>,
975
976 #[serde(default)]
978 pub entities: Option<EntitiesFilter>,
979
980 #[serde(default)]
983 pub casing: Option<Casing>,
984
985 #[serde(default)]
987 pub introspect: Option<IntrospectConfig>,
988
989 #[serde(default)]
991 pub verbose: bool,
992
993 #[serde(default)]
995 pub migrations: Option<MigrationsOpts>,
996}
997
998fn default_out() -> PathBuf {
999 PathBuf::from("./drizzle")
1000}
1001
1002const fn yes() -> bool {
1003 true
1004}
1005
1006impl DatabaseConfig {
1007 fn normalize_paths(&mut self, base_dir: &Path) {
1008 if self.out.is_relative() {
1011 self.out = base_dir.join(&self.out);
1012 }
1013
1014 let base = base_dir.to_string_lossy().replace('\\', "/");
1018 let base = base.trim_end_matches('/').to_string();
1019
1020 let normalize_one = |p: &str| -> String {
1021 let p_trim = p.trim();
1022 let is_abs = Path::new(p_trim).is_absolute() || p_trim.starts_with("\\\\");
1023 let joined = if is_abs || base.is_empty() || base == "." {
1024 p_trim.to_string()
1025 } else {
1026 format!("{base}/{p_trim}")
1027 };
1028 joined.replace('\\', "/")
1029 };
1030
1031 match &mut self.schema {
1032 Schema::One(p) => *p = normalize_one(p),
1033 Schema::Many(v) => {
1034 for p in v.iter_mut() {
1035 *p = normalize_one(p);
1036 }
1037 }
1038 }
1039 }
1040
1041 fn validate(&self, name: &str) -> Result<(), Error> {
1042 if let Some(d) = self.driver
1044 && !d.is_valid_for(self.dialect)
1045 {
1046 return Err(Error::InvalidDriver {
1047 driver: d,
1048 dialect: self.dialect,
1049 });
1050 }
1051
1052 if let Some(ref raw) = self.db_credentials {
1054 self.validate_creds(raw, name)?;
1055 }
1056
1057 if self.dialect != Dialect::Postgresql {
1059 if self.schema_filter.is_some() {
1060 return Err(Error::InvalidConfig(
1061 "schemaFilter is only supported for dialect = \"postgresql\"".into(),
1062 ));
1063 }
1064 if self.extensions_filters.is_some() {
1065 return Err(Error::InvalidConfig(
1066 "extensionsFilters is only supported for dialect = \"postgresql\"".into(),
1067 ));
1068 }
1069 if self.entities.is_some() {
1070 return Err(Error::InvalidConfig(
1071 "entities filter is only supported for dialect = \"postgresql\"".into(),
1072 ));
1073 }
1074 }
1075
1076 Ok(())
1077 }
1078
1079 fn validate_creds(&self, raw: &RawCreds, _name: &str) -> Result<(), Error> {
1080 let err = |msg: &str| Error::InvalidCredentials(msg.into());
1081
1082 match (self.dialect, raw) {
1085 (
1086 Dialect::Postgresql | Dialect::Mysql,
1087 RawCreds::Host { .. } | RawCreds::Url { .. },
1088 ) => {}
1089 (_, RawCreds::Host { .. }) => {
1090 return Err(err(
1091 "host-based dbCredentials are only supported for dialect = \"postgresql\" or \"mysql\"",
1092 ));
1093 }
1094 _ => {}
1095 }
1096
1097 if let RawCreds::D1 { .. } = raw {
1101 if self.dialect != Dialect::Sqlite {
1102 return Err(err(
1103 "D1 dbCredentials (accountId/databaseId/token) require dialect = \"sqlite\"",
1104 ));
1105 }
1106 if self.driver != Some(Driver::D1Http) {
1107 return Err(err(
1108 "D1 dbCredentials (accountId/databaseId/token) require driver = \"d1-http\"",
1109 ));
1110 }
1111 }
1112
1113 if self.driver == Some(Driver::D1Http) && !matches!(raw, RawCreds::D1 { .. }) {
1117 return Err(err(
1118 "driver = \"d1-http\" requires dbCredentials with accountId, databaseId, and token",
1119 ));
1120 }
1121
1122 if let RawCreds::AwsDataApi { .. } = raw {
1126 if self.dialect != Dialect::Postgresql {
1127 return Err(err(
1128 "AWS Data API dbCredentials (database/secretArn/resourceArn) require dialect = \"postgresql\"",
1129 ));
1130 }
1131 if self.driver != Some(Driver::AwsDataApi) {
1132 return Err(err(
1133 "AWS Data API dbCredentials (database/secretArn/resourceArn) require driver = \"aws-data-api\"",
1134 ));
1135 }
1136 }
1137
1138 if self.driver == Some(Driver::AwsDataApi) && !matches!(raw, RawCreds::AwsDataApi { .. }) {
1140 return Err(err(
1141 "driver = \"aws-data-api\" requires dbCredentials with database, secretArn, and resourceArn",
1142 ));
1143 }
1144
1145 match (self.dialect, raw) {
1147 (
1148 Dialect::Sqlite,
1149 RawCreds::Url {
1150 auth_token: Some(_),
1151 ..
1152 },
1153 ) => Err(err(
1154 "SQLite doesn't support authToken (use dialect = \"turso\")",
1155 )),
1156 (
1157 Dialect::Mysql,
1158 RawCreds::Url {
1159 auth_token: Some(_),
1160 ..
1161 },
1162 ) => Err(err("MySQL doesn't support authToken")),
1163 (
1164 Dialect::Sqlite,
1165 RawCreds::Url {
1166 url: ConfigValue::Inline(url),
1167 ..
1168 },
1169 ) if url.starts_with("libsql://") => Err(err(
1170 "libsql:// URLs require dialect = \"turso\" (for local SQLite files, use ./path.db)",
1171 )),
1172 (
1173 Dialect::Sqlite,
1174 RawCreds::Url {
1175 url: ConfigValue::Inline(url),
1176 ..
1177 },
1178 ) if url.starts_with("http://")
1179 || url.starts_with("https://")
1180 || url.starts_with("postgres://")
1181 || url.starts_with("postgresql://") =>
1182 {
1183 Err(err(
1184 "SQLite dbCredentials.url must be a local file path (not an http(s)/postgres URL)",
1185 ))
1186 }
1187 (
1188 Dialect::Turso,
1189 RawCreds::Url {
1190 url: ConfigValue::Inline(url),
1191 ..
1192 },
1193 ) if !url.starts_with("libsql://") && !url.starts_with("http") => {
1194 Err(err("Turso URL must start with libsql:// or http(s)://"))
1195 }
1196 (
1197 Dialect::Postgresql,
1198 RawCreds::Url {
1199 url: ConfigValue::Inline(url),
1200 ..
1201 },
1202 ) if !url.starts_with("postgres") => {
1203 Err(err("PostgreSQL URL must start with postgres://"))
1204 }
1205 (Dialect::Mysql, RawCreds::Host { ssl: Some(ssl), .. }) => ssl
1206 .mysql_mode()
1207 .map(|_| ())
1208 .map_err(Error::InvalidCredentials),
1209 (
1210 Dialect::Mysql,
1211 RawCreds::Url {
1212 url: ConfigValue::Inline(url),
1213 ..
1214 },
1215 ) if !url.starts_with("mysql://") => Err(err("MySQL URL must start with mysql://")),
1216 _ => Ok(()),
1217 }
1218 }
1219
1220 pub fn credentials(&self) -> Result<Option<Credentials>, Error> {
1231 let Some(raw) = self.db_credentials.as_ref() else {
1232 return Ok(None);
1233 };
1234
1235 let resolve_opt = |opt: &Option<ConfigValue>| -> Result<Option<Box<str>>, Error> {
1237 match opt.as_ref() {
1238 None => Ok(None),
1239 Some(e) => Ok(Some(e.resolve()?.into_boxed_str())),
1240 }
1241 };
1242
1243 let creds = match (self.dialect, raw) {
1244 (
1248 Dialect::Sqlite,
1249 RawCreds::D1 {
1250 account_id,
1251 database_id,
1252 token,
1253 },
1254 ) => Credentials::D1 {
1255 account_id: account_id.resolve()?.into_boxed_str(),
1256 database_id: database_id.resolve()?.into_boxed_str(),
1257 token: token.resolve()?.into_boxed_str(),
1258 },
1259 (
1262 Dialect::Postgresql,
1263 RawCreds::AwsDataApi {
1264 database,
1265 secret_arn,
1266 resource_arn,
1267 },
1268 ) => Credentials::AwsDataApi {
1269 database: database.resolve()?.into_boxed_str(),
1270 secret_arn: secret_arn.resolve()?.into_boxed_str(),
1271 resource_arn: resource_arn.resolve()?.into_boxed_str(),
1272 },
1273 (Dialect::Sqlite, RawCreds::Url { url, .. }) => Credentials::Sqlite {
1275 path: url.resolve()?.into_boxed_str(),
1276 },
1277 (Dialect::Turso, RawCreds::Url { url, auth_token }) => Credentials::Turso {
1279 url: url.resolve()?.into_boxed_str(),
1280 auth_token: resolve_opt(auth_token)?,
1281 },
1282 (Dialect::Postgresql, RawCreds::Url { url, .. }) => {
1284 Credentials::Postgres(PostgresCreds::Url(url.resolve()?.into_boxed_str()))
1285 }
1286 (
1288 Dialect::Mysql,
1289 RawCreds::Url {
1290 url,
1291 auth_token: None,
1292 },
1293 ) => Credentials::MySQL(MySQLCreds::Url(url.resolve()?.into_boxed_str())),
1294 (
1296 Dialect::Postgresql,
1297 RawCreds::Host {
1298 host,
1299 port,
1300 user,
1301 password,
1302 database,
1303 ssl,
1304 },
1305 ) => Credentials::Postgres(PostgresCreds::Host {
1306 host: host.resolve()?.into_boxed_str(),
1307 port: port.unwrap_or(5432),
1308 user: resolve_opt(user)?,
1309 password: resolve_opt(password)?,
1310 database: database.resolve()?.into_boxed_str(),
1311 ssl: ssl
1312 .as_ref()
1313 .map_or(Ok(PostgresSslMode::Disable), SslVal::postgres_mode)
1314 .map_err(Error::InvalidCredentials)?,
1315 }),
1316 (
1318 Dialect::Mysql,
1319 RawCreds::Host {
1320 host,
1321 port,
1322 user,
1323 password,
1324 database,
1325 ssl,
1326 },
1327 ) => Credentials::MySQL(MySQLCreds::Host {
1328 host: host.resolve()?.into_boxed_str(),
1329 port: port.unwrap_or(3306),
1330 user: resolve_opt(user)?,
1331 password: resolve_opt(password)?,
1332 database: database.resolve()?.into_boxed_str(),
1333 ssl: ssl
1334 .as_ref()
1335 .map_or(Ok(MySQLSslMode::Disable), SslVal::mysql_mode)
1336 .map_err(Error::InvalidCredentials)?,
1337 }),
1338 _ => return Ok(None),
1339 };
1340
1341 Ok(Some(creds))
1342 }
1343
1344 #[inline]
1346 #[must_use]
1347 pub fn migrations_dir(&self) -> &Path {
1348 &self.out
1349 }
1350
1351 #[inline]
1353 #[must_use]
1354 pub fn meta_dir(&self) -> PathBuf {
1355 self.out.join("meta")
1356 }
1357
1358 #[inline]
1360 #[must_use]
1361 pub fn journal_path(&self) -> PathBuf {
1362 self.meta_dir().join("_journal.json")
1363 }
1364
1365 #[must_use]
1367 pub fn schema_display(&self) -> String {
1368 match &self.schema {
1369 Schema::One(s) => s.clone(),
1370 Schema::Many(v) => v.join(", "),
1371 }
1372 }
1373
1374 pub fn schema_files(&self) -> Result<Vec<PathBuf>, Error> {
1382 let files = resolve_schema_patterns(self.schema.iter())?;
1383 if files.is_empty() {
1384 return Err(Error::NoSchemaFiles(self.schema_display()));
1385 }
1386 Ok(files)
1387 }
1388
1389 #[inline]
1391 #[must_use]
1392 pub fn effective_casing(&self) -> Casing {
1393 self.casing.unwrap_or_default()
1394 }
1395
1396 #[inline]
1398 #[must_use]
1399 pub fn effective_introspect_casing(&self) -> IntrospectCasing {
1400 self.introspect
1401 .as_ref()
1402 .map(|i| i.casing)
1403 .unwrap_or_default()
1404 }
1405
1406 #[inline]
1408 #[must_use]
1409 pub fn effective_entities(&self) -> EntitiesFilter {
1410 self.entities.clone().unwrap_or_default()
1411 }
1412
1413 #[must_use]
1416 pub fn should_include_role(&self, role_name: &str) -> bool {
1417 self.entities
1418 .as_ref()
1419 .is_some_and(|e| e.roles.should_include(role_name))
1420 }
1421
1422 #[must_use]
1424 pub fn roles_enabled(&self) -> bool {
1425 self.entities.as_ref().is_some_and(|e| e.roles.is_enabled())
1426 }
1427
1428 #[must_use]
1431 pub fn extensions(&self) -> &[Extension] {
1432 self.extensions_filters.as_deref().unwrap_or(&[])
1433 }
1434
1435 #[must_use]
1437 pub fn has_extension(&self, ext: Extension) -> bool {
1438 self.extensions_filters
1439 .as_ref()
1440 .is_some_and(|v| v.contains(&ext))
1441 }
1442
1443 #[must_use]
1445 pub fn migrations_table(&self) -> &str {
1446 self.migrations
1447 .as_ref()
1448 .and_then(|m| m.table.as_deref())
1449 .unwrap_or("__drizzle_migrations")
1450 }
1451
1452 #[must_use]
1455 pub fn migrations_schema(&self) -> &str {
1456 self.migrations
1457 .as_ref()
1458 .and_then(|m| m.schema.as_deref())
1459 .unwrap_or("drizzle")
1460 }
1461
1462 #[must_use]
1471 pub fn bundle_enabled(&self) -> bool {
1472 if let Some(explicit) = self.migrations.as_ref().and_then(|m| m.bundle) {
1473 return explicit;
1474 }
1475 matches!(self.driver, Some(Driver::DurableSqlite))
1476 }
1477}
1478
1479#[derive(Debug, Clone, Deserialize)]
1485struct MultiDbConfig {
1486 databases: HashMap<String, DatabaseConfig>,
1487}
1488
1489#[derive(Debug, Clone)]
1513pub struct Config {
1514 databases: HashMap<String, DatabaseConfig>,
1516 is_single: bool,
1518}
1519
1520pub const DEFAULT_DB: &str = "default";
1522
1523impl Config {
1524 pub fn load() -> Result<Self, Error> {
1531 Self::load_from(Path::new(CONFIG_FILE))
1532 }
1533
1534 pub fn load_from(path: &Path) -> Result<Self, Error> {
1569 let content = std::fs::read_to_string(path).map_err(|e| {
1570 if e.kind() == std::io::ErrorKind::NotFound {
1571 Error::NotFound(path.into())
1572 } else {
1573 Error::Io(path.into(), e)
1574 }
1575 })?;
1576
1577 Self::load_from_str(&content, path)
1578 }
1579
1580 fn load_from_str(content: &str, path: &Path) -> Result<Self, Error> {
1582 let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
1583
1584 if let Ok(multi) = toml::from_str::<MultiDbConfig>(content)
1586 && !multi.databases.is_empty()
1587 {
1588 let mut config = Self {
1589 databases: multi.databases,
1590 is_single: false,
1591 };
1592 for db in config.databases.values_mut() {
1593 db.normalize_paths(base_dir);
1594 }
1595 config.validate()?;
1596 return Ok(config);
1597 }
1598
1599 let db_config: DatabaseConfig =
1601 toml::from_str(content).map_err(|e| Error::Parse(path.into(), e))?;
1602
1603 let mut databases = HashMap::new();
1604 databases.insert(DEFAULT_DB.to_string(), db_config);
1605
1606 let mut config = Self {
1607 databases,
1608 is_single: true,
1609 };
1610 for db in config.databases.values_mut() {
1611 db.normalize_paths(base_dir);
1612 }
1613 config.validate()?;
1614 Ok(config)
1615 }
1616
1617 fn validate(&self) -> Result<(), Error> {
1618 for (name, db) in &self.databases {
1619 db.validate(name)?;
1620 }
1621 Ok(())
1622 }
1623
1624 #[must_use]
1626 pub const fn is_single_database(&self) -> bool {
1627 self.is_single
1628 }
1629
1630 pub fn database_names(&self) -> impl Iterator<Item = &str> {
1633 self.databases.keys().map(String::as_str)
1634 }
1635
1636 pub fn database(&self, name: Option<&str>) -> Result<&DatabaseConfig, Error> {
1649 name.map_or_else(
1650 || {
1651 if self.is_single {
1653 self.databases.get(DEFAULT_DB).ok_or(Error::NoDatabases)
1654 } else if self.databases.len() == 1 {
1655 self.databases.values().next().ok_or(Error::NoDatabases)
1656 } else {
1657 Err(Error::DatabaseRequired(
1658 self.databases.keys().cloned().collect(),
1659 ))
1660 }
1661 },
1662 |name| {
1663 if self.is_single {
1664 self.databases.get(DEFAULT_DB).ok_or(Error::NoDatabases)
1666 } else {
1667 self.databases
1668 .get(name)
1669 .ok_or_else(|| Error::DatabaseNotFound(name.to_string()))
1670 }
1671 },
1672 )
1673 }
1674
1675 pub fn default_database(&self) -> Result<&DatabaseConfig, Error> {
1681 self.database(None)
1682 }
1683
1684 #[must_use]
1690 pub fn dialect(&self) -> Dialect {
1691 self.default_database()
1692 .map(|d| d.dialect)
1693 .unwrap_or_default()
1694 }
1695
1696 pub fn credentials(&self) -> Result<Option<Credentials>, Error> {
1703 self.default_database()?.credentials()
1704 }
1705
1706 #[must_use]
1709 pub fn migrations_dir(&self) -> &Path {
1710 self.default_database()
1711 .map_or_else(|_| Path::new("./drizzle"), |d| d.migrations_dir())
1712 }
1713
1714 #[must_use]
1717 pub fn journal_path(&self) -> PathBuf {
1718 self.default_database().map_or_else(
1719 |_| PathBuf::from("./drizzle/meta/_journal.json"),
1720 DatabaseConfig::journal_path,
1721 )
1722 }
1723
1724 #[must_use]
1727 pub fn schema_display(&self) -> String {
1728 self.default_database()
1729 .map_or_else(|_| "src/schema.rs".into(), DatabaseConfig::schema_display)
1730 }
1731
1732 pub fn schema_files(&self) -> Result<Vec<PathBuf>, Error> {
1740 self.default_database()?.schema_files()
1741 }
1742
1743 #[must_use]
1746 pub fn base_dialect(&self) -> drizzle_types::Dialect {
1747 self.dialect().to_base()
1748 }
1749}
1750
1751pub fn resolve_schema_patterns<'a>(
1765 patterns: impl IntoIterator<Item = &'a str>,
1766) -> Result<Vec<PathBuf>, Error> {
1767 let mut files = Vec::new();
1768
1769 for pattern in patterns {
1770 let pat = pattern.trim();
1771 let is_glob = pat.contains('*') || pat.contains('?') || pat.contains('[');
1772
1773 if !is_glob {
1774 let path = PathBuf::from(pat);
1776 let normalized = PathBuf::from(pat.replace('\\', "/"));
1777 if path.is_file() {
1778 files.push(path);
1779 } else if normalized.is_file() {
1780 files.push(normalized);
1781 } else {
1782 return Err(Error::SchemaPathNotFound(pat.into()));
1783 }
1784 continue;
1785 }
1786
1787 let pat_norm = pat.replace('\\', "/");
1789 let paths = glob::glob(&pat_norm).map_err(|e| Error::Glob(pat.into(), e))?;
1790 let mut matched = false;
1791 for entry in paths {
1792 let path = entry.map_err(|e| Error::GlobRead(pat.into(), e))?;
1793 if path.is_file() {
1795 files.push(path);
1796 matched = true;
1797 }
1798 }
1799 if !matched {
1800 return Err(Error::SchemaPatternMatchedNothing(pat.into()));
1801 }
1802 }
1803
1804 files.sort();
1805 files.dedup();
1806 Ok(files)
1807}
1808
1809#[derive(Debug, thiserror::Error)]
1815pub enum Error {
1816 #[error("config not found: {}", .0.display())]
1818 NotFound(PathBuf),
1819
1820 #[error("failed to read {}: {}", .0.display(), .1)]
1822 Io(PathBuf, #[source] std::io::Error),
1823
1824 #[error("failed to parse {}: {}", .0.display(), .1)]
1826 Parse(PathBuf, #[source] toml::de::Error),
1827
1828 #[error("driver '{driver}' invalid for {dialect} dialect")]
1830 InvalidDriver {
1831 driver: Driver,
1833 dialect: Dialect,
1835 },
1836
1837 #[error("invalid credentials: {0}")]
1839 InvalidCredentials(String),
1840
1841 #[error("invalid config: {0}")]
1843 InvalidConfig(String),
1844
1845 #[error("invalid glob '{0}': {1}")]
1847 Glob(String, #[source] glob::PatternError),
1848
1849 #[error("failed to read a path matched by glob '{0}': {1}")]
1851 GlobRead(String, #[source] glob::GlobError),
1852
1853 #[error("schema path '{0}' does not exist")]
1855 SchemaPathNotFound(String),
1856
1857 #[error("schema pattern '{0}' matches no file")]
1859 SchemaPatternMatchedNothing(String),
1860
1861 #[error("no schema files found: {0}")]
1863 NoSchemaFiles(String),
1864
1865 #[error("environment variable '{0}' not found")]
1867 EnvNotFound(String),
1868
1869 #[error("environment variable '{0}' invalid: {1}")]
1871 EnvInvalid(String, String),
1872
1873 #[error("no databases configured")]
1875 NoDatabases,
1876
1877 #[error("database '{0}' not found")]
1879 DatabaseNotFound(String),
1880
1881 #[error("multiple databases configured, use --db to specify: {}", .0.join(", "))]
1883 DatabaseRequired(Vec<String>),
1884}
1885
1886impl From<ConfigValueError> for Error {
1887 fn from(err: ConfigValueError) -> Self {
1888 match err {
1889 ConfigValueError::NotPresent(var) => Self::EnvNotFound(var),
1890 ConfigValueError::NotUnicode(var) => {
1891 Self::EnvInvalid(var, "contains invalid unicode".into())
1892 }
1893 }
1894 }
1895}
1896
1897#[cfg(test)]
1902mod tests {
1903 use super::*;
1904 use std::fs;
1905 use tempfile::TempDir;
1906
1907 #[test]
1908 fn sqlite() {
1909 let cfg = Config::load_from_str(
1910 r#"
1911 dialect = "sqlite"
1912 [dbCredentials]
1913 url = "./dev.db"
1914 "#,
1915 Path::new("test.toml"),
1916 )
1917 .unwrap();
1918 assert!(cfg.is_single_database());
1919 assert!(matches!(
1920 cfg.credentials().unwrap(),
1921 Some(Credentials::Sqlite { .. })
1922 ));
1923 }
1924
1925 #[test]
1926 fn postgres_url() {
1927 let cfg = Config::load_from_str(
1928 r#"
1929 dialect = "postgresql"
1930 [dbCredentials]
1931 url = "postgres://localhost/db"
1932 "#,
1933 Path::new("test.toml"),
1934 )
1935 .unwrap();
1936 assert!(matches!(
1937 cfg.credentials().unwrap(),
1938 Some(Credentials::Postgres(PostgresCreds::Url(_)))
1939 ));
1940 }
1941
1942 #[test]
1943 fn mysql_url_credentials_and_driver_parse() {
1944 let cfg = Config::load_from_str(
1945 r#"
1946 dialect = "mysql"
1947 driver = "mysql-async"
1948 [dbCredentials]
1949 url = "mysql://user:secret@localhost:3306/app"
1950 "#,
1951 Path::new("test.toml"),
1952 )
1953 .unwrap();
1954 let db = cfg.default_database().unwrap();
1955 assert_eq!(db.dialect, Dialect::Mysql);
1956 assert_eq!(db.driver, Some(Driver::MysqlAsync));
1957 assert!(matches!(
1958 db.credentials().unwrap(),
1959 Some(Credentials::MySQL(MySQLCreds::Url(_)))
1960 ));
1961 }
1962
1963 #[test]
1964 fn mysql_host_credentials_use_mysql_defaults() {
1965 let cfg = Config::load_from_str(
1966 r#"
1967 dialect = "mysql"
1968 driver = "mysql-sync"
1969 [dbCredentials]
1970 host = "db.example.com"
1971 user = "app"
1972 password = "secret"
1973 database = "app_db"
1974 ssl = "verify-identity"
1975 "#,
1976 Path::new("test.toml"),
1977 )
1978 .unwrap();
1979 match cfg.default_database().unwrap().credentials().unwrap() {
1980 Some(Credentials::MySQL(MySQLCreds::Host {
1981 port,
1982 ssl,
1983 database,
1984 ..
1985 })) => {
1986 assert_eq!(port, 3306);
1987 assert_eq!(ssl, MySQLSslMode::VerifyIdentity);
1988 assert_eq!(database.as_ref(), "app_db");
1989 }
1990 other => panic!("unexpected credentials: {other:?}"),
1991 }
1992 }
1993
1994 #[test]
1995 fn mysql_host_credentials_reject_unrepresentable_preferred_tls() {
1996 let error = Config::load_from_str(
1997 r#"
1998 dialect = "mysql"
1999 [dbCredentials]
2000 host = "db.example.com"
2001 database = "app_db"
2002 ssl = "preferred"
2003 "#,
2004 Path::new("test.toml"),
2005 )
2006 .expect_err("host credentials cannot express opportunistic TLS");
2007
2008 assert!(
2009 error.to_string().contains("invalid MySQL SSL mode"),
2010 "{error}"
2011 );
2012 }
2013
2014 #[test]
2015 fn mysql_rejects_cross_dialect_driver_and_url() {
2016 let driver_error = Config::load_from_str(
2017 r#"
2018 dialect = "mysql"
2019 driver = "postgres-sync"
2020 [dbCredentials]
2021 url = "mysql://localhost/app"
2022 "#,
2023 Path::new("test.toml"),
2024 )
2025 .expect_err("postgres driver must not target mysql");
2026 assert!(driver_error.to_string().contains("invalid for mysql"));
2027
2028 let url_error = Config::load_from_str(
2029 r#"
2030 dialect = "mysql"
2031 [dbCredentials]
2032 url = "postgres://localhost/app"
2033 "#,
2034 Path::new("test.toml"),
2035 )
2036 .expect_err("postgres URL must not target mysql");
2037 assert!(url_error.to_string().contains("must start with mysql://"));
2038 }
2039
2040 #[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
2041 #[test]
2042 fn postgres_host_credentials_are_structured_without_url_encoding() {
2043 let credentials = PostgresCreds::Host {
2044 host: "db.example.com".into(),
2045 port: 5432,
2046 user: Some("user@tenant".into()),
2047 password: Some("p:a/ss% word".into()),
2048 database: "app/db name".into(),
2049 ssl: PostgresSslMode::VerifyFull,
2050 };
2051
2052 let connection = credentials.connection_config().expect("build config");
2053 assert_eq!(connection.config.get_user(), Some("user@tenant"));
2054 assert_eq!(connection.config.get_password(), Some(&b"p:a/ss% word"[..]));
2055 assert_eq!(connection.config.get_dbname(), Some("app/db name"));
2056 assert_eq!(
2057 connection.config.get_ssl_mode(),
2058 tokio_postgres::config::SslMode::Require
2059 );
2060 assert_eq!(connection.ssl, PostgresSslMode::VerifyFull);
2061 }
2062
2063 #[test]
2064 fn multi_database() {
2065 let cfg = Config::load_from_str(
2066 r#"
2067 [databases.dev]
2068 dialect = "sqlite"
2069 out = "./drizzle/sqlite"
2070 [databases.dev.dbCredentials]
2071 url = "./dev.db"
2072
2073 [databases.prod]
2074 dialect = "postgresql"
2075 out = "./drizzle/postgres"
2076 [databases.prod.dbCredentials]
2077 url = "postgres://localhost/db"
2078 "#,
2079 Path::new("test.toml"),
2080 )
2081 .unwrap();
2082
2083 assert!(!cfg.is_single_database());
2084 let names: Vec<_> = cfg.database_names().collect();
2085 assert!(names.contains(&"dev"));
2086 assert!(names.contains(&"prod"));
2087
2088 let dev = cfg.database(Some("dev")).unwrap();
2089 assert_eq!(dev.dialect, Dialect::Sqlite);
2090
2091 let prod = cfg.database(Some("prod")).unwrap();
2092 assert_eq!(prod.dialect, Dialect::Postgresql);
2093 }
2094
2095 #[test]
2096 fn multi_database_requires_selection() {
2097 let cfg = Config::load_from_str(
2098 r#"
2099 [databases.a]
2100 dialect = "sqlite"
2101 [databases.b]
2102 dialect = "postgresql"
2103 "#,
2104 Path::new("test.toml"),
2105 )
2106 .unwrap();
2107
2108 assert!(cfg.database(None).is_err());
2110 }
2111
2112 #[test]
2113 fn env_var_syntax() {
2114 let cfg = Config::load_from_str(
2115 r#"
2116 dialect = "postgresql"
2117 [dbCredentials]
2118 url = { env = "DATABASE_URL" }
2119 "#,
2120 Path::new("test.toml"),
2121 )
2122 .unwrap();
2123 assert!(cfg.is_single_database());
2124 }
2125
2126 #[test]
2127 fn casing_options() {
2128 let cfg = Config::load_from_str(
2129 r#"
2130 dialect = "postgresql"
2131 casing = "snake_case"
2132 [dbCredentials]
2133 url = "postgres://localhost/db"
2134 "#,
2135 Path::new("test.toml"),
2136 )
2137 .unwrap();
2138 let db = cfg.default_database().unwrap();
2139 assert_eq!(db.effective_casing(), Casing::SnakeCase);
2140
2141 let cfg2 = Config::load_from_str(
2143 r#"
2144 dialect = "postgresql"
2145 [dbCredentials]
2146 url = "postgres://localhost/db"
2147 "#,
2148 Path::new("test.toml"),
2149 )
2150 .unwrap();
2151 let db2 = cfg2.default_database().unwrap();
2152 assert_eq!(db2.effective_casing(), Casing::CamelCase);
2153 }
2154
2155 #[test]
2156 fn introspect_casing() {
2157 let cfg = Config::load_from_str(
2158 r#"
2159 dialect = "postgresql"
2160 [introspect]
2161 casing = "preserve"
2162 [dbCredentials]
2163 url = "postgres://localhost/db"
2164 "#,
2165 Path::new("test.toml"),
2166 )
2167 .unwrap();
2168 let db = cfg.default_database().unwrap();
2169 assert_eq!(db.effective_introspect_casing(), IntrospectCasing::Preserve);
2170 }
2171
2172 #[test]
2173 fn entities_roles_filter() {
2174 let cfg = Config::load_from_str(
2176 r#"
2177 dialect = "postgresql"
2178 [entities]
2179 roles = true
2180 [dbCredentials]
2181 url = "postgres://localhost/db"
2182 "#,
2183 Path::new("test.toml"),
2184 )
2185 .unwrap();
2186 let db = cfg.default_database().unwrap();
2187 assert!(db.roles_enabled());
2188 assert!(db.should_include_role("my_role"));
2189
2190 let cfg2 = Config::load_from_str(
2192 r#"
2193 dialect = "postgresql"
2194 [entities.roles]
2195 provider = "supabase"
2196 [dbCredentials]
2197 url = "postgres://localhost/db"
2198 "#,
2199 Path::new("test.toml"),
2200 )
2201 .unwrap();
2202 let db2 = cfg2.default_database().unwrap();
2203 assert!(db2.roles_enabled());
2204 assert!(!db2.should_include_role("anon")); assert!(db2.should_include_role("my_custom_role"));
2206
2207 let cfg3 = Config::load_from_str(
2209 r#"
2210 dialect = "postgresql"
2211 [entities.roles]
2212 provider = "neon"
2213 [dbCredentials]
2214 url = "postgres://localhost/db"
2215 "#,
2216 Path::new("test.toml"),
2217 )
2218 .unwrap();
2219 let db3 = cfg3.default_database().unwrap();
2220 for reserved in ["neon_superuser", "neon_service", "zenith_admin", "none"] {
2221 assert!(!db3.should_include_role(reserved), "{reserved}");
2222 }
2223 assert!(db3.should_include_role("my_custom_role"));
2224 }
2225
2226 #[test]
2227 fn extensions_filter() {
2228 let cfg = Config::load_from_str(
2229 r#"
2230 dialect = "postgresql"
2231 extensionsFilters = ["postgis"]
2232 [dbCredentials]
2233 url = "postgres://localhost/db"
2234 "#,
2235 Path::new("test.toml"),
2236 )
2237 .unwrap();
2238 let db = cfg.default_database().unwrap();
2239 assert!(db.has_extension(Extension::Postgis));
2240 }
2241
2242 #[test]
2243 fn rejects_postgres_only_filters_for_sqlite() {
2244 let err = Config::load_from_str(
2245 r#"
2246 dialect = "sqlite"
2247 schemaFilter = ["public"]
2248 [dbCredentials]
2249 url = "./dev.db"
2250 "#,
2251 Path::new("test.toml"),
2252 )
2253 .expect_err("sqlite should reject schemaFilter");
2254 assert_eq!(
2255 err.to_string(),
2256 "invalid config: schemaFilter is only supported for dialect = \"postgresql\""
2257 );
2258
2259 let err = Config::load_from_str(
2260 r#"
2261 dialect = "sqlite"
2262 extensionsFilters = ["postgis"]
2263 [dbCredentials]
2264 url = "./dev.db"
2265 "#,
2266 Path::new("test.toml"),
2267 )
2268 .expect_err("sqlite should reject extensionsFilters");
2269 assert_eq!(
2270 err.to_string(),
2271 "invalid config: extensionsFilters is only supported for dialect = \"postgresql\""
2272 );
2273 }
2274
2275 #[test]
2276 fn rejects_entities_filter_for_turso() {
2277 let err = Config::load_from_str(
2278 r#"
2279 dialect = "turso"
2280 [entities]
2281 roles = true
2282 [dbCredentials]
2283 url = "libsql://example.turso.io"
2284 "#,
2285 Path::new("test.toml"),
2286 )
2287 .expect_err("turso should reject entities filter");
2288 assert_eq!(
2289 err.to_string(),
2290 "invalid config: entities filter is only supported for dialect = \"postgresql\""
2291 );
2292 }
2293
2294 #[test]
2295 fn migrations_config() {
2296 let cfg = Config::load_from_str(
2297 r#"
2298 dialect = "postgresql"
2299 [migrations]
2300 table = "custom_migrations"
2301 schema = "custom_schema"
2302 [dbCredentials]
2303 url = "postgres://localhost/db"
2304 "#,
2305 Path::new("test.toml"),
2306 )
2307 .unwrap();
2308 let db = cfg.default_database().unwrap();
2309 assert_eq!(db.migrations_table(), "custom_migrations");
2310 assert_eq!(db.migrations_schema(), "custom_schema");
2311
2312 let cfg2 = Config::load_from_str(
2314 r#"
2315 dialect = "postgresql"
2316 [dbCredentials]
2317 url = "postgres://localhost/db"
2318 "#,
2319 Path::new("test.toml"),
2320 )
2321 .unwrap();
2322 let db2 = cfg2.default_database().unwrap();
2323 assert_eq!(db2.migrations_table(), "__drizzle_migrations");
2324 assert_eq!(db2.migrations_schema(), "drizzle");
2325 }
2326
2327 #[test]
2328 fn resolves_paths_relative_to_config_dir() {
2329 let tmp = TempDir::new().unwrap();
2330 let cfg_dir = tmp.path().join("cfg");
2331 fs::create_dir_all(&cfg_dir).unwrap();
2332
2333 let schema_path = cfg_dir.join("schema.rs");
2335 fs::write(&schema_path, "#[allow(dead_code)]\npub struct X;").unwrap();
2336
2337 let cfg_path = cfg_dir.join("drizzle.config.toml");
2338 let cfg = Config::load_from_str(
2339 r#"
2340 dialect = "sqlite"
2341 schema = "schema.rs"
2342 out = "./drizzle"
2343 [dbCredentials]
2344 url = "./dev.db"
2345 "#,
2346 &cfg_path,
2347 )
2348 .unwrap();
2349
2350 let db = cfg.default_database().unwrap();
2351 assert_eq!(db.migrations_dir(), cfg_dir.join("./drizzle").as_path());
2352
2353 let files = db.schema_files().unwrap();
2354 assert_eq!(files.len(), 1);
2355 assert_eq!(files[0], schema_path);
2356 }
2357
2358 #[test]
2359 fn rejects_host_credentials_for_sqlite() {
2360 let err = Config::load_from_str(
2361 r#"
2362 dialect = "sqlite"
2363 [dbCredentials]
2364 host = "localhost"
2365 database = "db"
2366 "#,
2367 Path::new("test.toml"),
2368 )
2369 .unwrap_err();
2370
2371 assert_eq!(
2372 err.to_string(),
2373 "invalid credentials: host-based dbCredentials are only supported for dialect = \"postgresql\" or \"mysql\""
2374 );
2375 }
2376
2377 #[test]
2382 fn d1_http_credentials_parse() {
2383 let cfg = Config::load_from_str(
2384 r#"
2385 dialect = "sqlite"
2386 driver = "d1-http"
2387 [dbCredentials]
2388 accountId = "acc_abc"
2389 databaseId = "db_xyz"
2390 token = "tok_123"
2391 "#,
2392 Path::new("test.toml"),
2393 )
2394 .unwrap();
2395
2396 let db = cfg.default_database().unwrap();
2397 assert_eq!(db.driver, Some(Driver::D1Http));
2398 match db.credentials().unwrap() {
2399 Some(Credentials::D1 {
2400 account_id,
2401 database_id,
2402 token,
2403 }) => {
2404 assert_eq!(&*account_id, "acc_abc");
2405 assert_eq!(&*database_id, "db_xyz");
2406 assert_eq!(&*token, "tok_123");
2407 }
2408 other => panic!("expected Credentials::D1, got {other:?}"),
2409 }
2410 }
2411
2412 #[test]
2413 fn d1_http_credentials_resolve_from_env() {
2414 unsafe {
2416 std::env::set_var("TEST_D1_ACCT", "env_acct");
2417 std::env::set_var("TEST_D1_DB", "env_db");
2418 std::env::set_var("TEST_D1_TOKEN", "env_token");
2419 }
2420 let cfg = Config::load_from_str(
2421 r#"
2422 dialect = "sqlite"
2423 driver = "d1-http"
2424 [dbCredentials]
2425 accountId = { env = "TEST_D1_ACCT" }
2426 databaseId = { env = "TEST_D1_DB" }
2427 token = { env = "TEST_D1_TOKEN" }
2428 "#,
2429 Path::new("test.toml"),
2430 )
2431 .unwrap();
2432
2433 match cfg.default_database().unwrap().credentials().unwrap() {
2434 Some(Credentials::D1 {
2435 account_id,
2436 database_id,
2437 token,
2438 }) => {
2439 assert_eq!(&*account_id, "env_acct");
2440 assert_eq!(&*database_id, "env_db");
2441 assert_eq!(&*token, "env_token");
2442 }
2443 other => panic!("expected Credentials::D1, got {other:?}"),
2444 }
2445 }
2446
2447 #[test]
2448 fn d1_credentials_require_sqlite_dialect() {
2449 let err = Config::load_from_str(
2450 r#"
2451 dialect = "postgresql"
2452 [dbCredentials]
2453 accountId = "acc"
2454 databaseId = "db"
2455 token = "tok"
2456 "#,
2457 Path::new("test.toml"),
2458 )
2459 .unwrap_err();
2460 assert!(
2461 err.to_string().contains("D1 dbCredentials"),
2462 "expected D1-specific error, got: {err}"
2463 );
2464 }
2465
2466 #[test]
2467 fn d1_credentials_require_d1_http_driver() {
2468 let err = Config::load_from_str(
2470 r#"
2471 dialect = "sqlite"
2472 driver = "rusqlite"
2473 [dbCredentials]
2474 accountId = "acc"
2475 databaseId = "db"
2476 token = "tok"
2477 "#,
2478 Path::new("test.toml"),
2479 )
2480 .unwrap_err();
2481 assert!(
2482 err.to_string().contains("driver = \"d1-http\""),
2483 "expected d1-http driver error, got: {err}"
2484 );
2485 }
2486
2487 #[test]
2488 fn d1_http_driver_requires_d1_credentials() {
2489 let err = Config::load_from_str(
2491 r#"
2492 dialect = "sqlite"
2493 driver = "d1-http"
2494 [dbCredentials]
2495 url = "./dev.db"
2496 "#,
2497 Path::new("test.toml"),
2498 )
2499 .unwrap_err();
2500 assert!(
2501 err.to_string().contains("accountId, databaseId, and token"),
2502 "expected d1-http creds-shape error, got: {err}"
2503 );
2504 }
2505
2506 #[test]
2507 fn durable_sqlite_no_credentials_ok() {
2508 let cfg = Config::load_from_str(
2511 r#"
2512 dialect = "sqlite"
2513 driver = "durable-sqlite"
2514 "#,
2515 Path::new("test.toml"),
2516 )
2517 .unwrap();
2518
2519 let db = cfg.default_database().unwrap();
2520 assert_eq!(db.driver, Some(Driver::DurableSqlite));
2521 assert!(db.credentials().unwrap().is_none());
2522 assert!(
2524 db.bundle_enabled(),
2525 "durable-sqlite should auto-enable bundle"
2526 );
2527 }
2528
2529 #[test]
2530 fn durable_sqlite_explicit_bundle_false_respected() {
2531 let cfg = Config::load_from_str(
2533 r#"
2534 dialect = "sqlite"
2535 driver = "durable-sqlite"
2536 [migrations]
2537 bundle = false
2538 "#,
2539 Path::new("test.toml"),
2540 )
2541 .unwrap();
2542 assert!(!cfg.default_database().unwrap().bundle_enabled());
2543 }
2544
2545 #[test]
2546 fn durable_sqlite_rejects_non_sqlite_dialect() {
2547 let err = Config::load_from_str(
2548 r#"
2549 dialect = "postgresql"
2550 driver = "durable-sqlite"
2551 [dbCredentials]
2552 url = "postgres://localhost/db"
2553 "#,
2554 Path::new("test.toml"),
2555 )
2556 .unwrap_err();
2557 assert!(
2558 err.to_string().contains("invalid for postgresql"),
2559 "expected dialect/driver mismatch error, got: {err}"
2560 );
2561 }
2562
2563 #[test]
2564 fn driver_valid_for_sqlite_includes_cloudflare() {
2565 let drivers = Driver::valid_for(Dialect::Sqlite);
2566 assert!(drivers.contains(&Driver::Rusqlite));
2567 assert!(drivers.contains(&Driver::D1Http));
2568 assert!(drivers.contains(&Driver::DurableSqlite));
2569 for drv in [Driver::D1Http, Driver::DurableSqlite] {
2571 assert!(!drv.is_valid_for(Dialect::Postgresql));
2572 assert!(!drv.is_valid_for(Dialect::Turso));
2573 }
2574 }
2575
2576 #[test]
2577 fn driver_is_codegen_only_flag() {
2578 assert!(Driver::DurableSqlite.is_codegen_only());
2579 assert!(!Driver::D1Http.is_codegen_only());
2580 assert!(!Driver::Rusqlite.is_codegen_only());
2581 assert!(!Driver::AwsDataApi.is_codegen_only());
2582 }
2583
2584 #[test]
2589 fn aws_data_api_credentials_parse() {
2590 let cfg = Config::load_from_str(
2591 r#"
2592 dialect = "postgresql"
2593 driver = "aws-data-api"
2594 [dbCredentials]
2595 database = "mydb"
2596 secretArn = "arn:aws:secretsmanager:us-east-1:123:secret:db-xyz"
2597 resourceArn = "arn:aws:rds:us-east-1:123:cluster:my-aurora"
2598 "#,
2599 Path::new("test.toml"),
2600 )
2601 .unwrap();
2602
2603 let db = cfg.default_database().unwrap();
2604 assert_eq!(db.driver, Some(Driver::AwsDataApi));
2605 match db.credentials().unwrap() {
2606 Some(Credentials::AwsDataApi {
2607 database,
2608 secret_arn,
2609 resource_arn,
2610 }) => {
2611 assert_eq!(&*database, "mydb");
2612 assert!(secret_arn.starts_with("arn:aws:secretsmanager"));
2613 assert!(resource_arn.starts_with("arn:aws:rds"));
2614 }
2615 other => panic!("expected Credentials::AwsDataApi, got {other:?}"),
2616 }
2617 }
2618
2619 #[test]
2620 fn aws_data_api_credentials_resolve_from_env() {
2621 unsafe {
2622 std::env::set_var("TEST_AWS_DB", "envdb");
2623 std::env::set_var("TEST_AWS_SECRET", "arn:env:secret");
2624 std::env::set_var("TEST_AWS_RESOURCE", "arn:env:resource");
2625 }
2626 let cfg = Config::load_from_str(
2627 r#"
2628 dialect = "postgresql"
2629 driver = "aws-data-api"
2630 [dbCredentials]
2631 database = { env = "TEST_AWS_DB" }
2632 secretArn = { env = "TEST_AWS_SECRET" }
2633 resourceArn = { env = "TEST_AWS_RESOURCE" }
2634 "#,
2635 Path::new("test.toml"),
2636 )
2637 .unwrap();
2638
2639 match cfg.default_database().unwrap().credentials().unwrap() {
2640 Some(Credentials::AwsDataApi {
2641 database,
2642 secret_arn,
2643 resource_arn,
2644 }) => {
2645 assert_eq!(&*database, "envdb");
2646 assert_eq!(&*secret_arn, "arn:env:secret");
2647 assert_eq!(&*resource_arn, "arn:env:resource");
2648 }
2649 other => panic!("expected Credentials::AwsDataApi, got {other:?}"),
2650 }
2651 }
2652
2653 #[test]
2654 fn aws_data_api_requires_postgres_dialect() {
2655 let err = Config::load_from_str(
2656 r#"
2657 dialect = "sqlite"
2658 [dbCredentials]
2659 database = "mydb"
2660 secretArn = "arn:aws:secretsmanager:..."
2661 resourceArn = "arn:aws:rds:..."
2662 "#,
2663 Path::new("test.toml"),
2664 )
2665 .unwrap_err();
2666 assert!(
2667 err.to_string().contains("AWS Data API dbCredentials"),
2668 "expected AWS-specific error, got: {err}"
2669 );
2670 }
2671
2672 #[test]
2673 fn aws_data_api_requires_aws_data_api_driver() {
2674 let err = Config::load_from_str(
2676 r#"
2677 dialect = "postgresql"
2678 driver = "tokio-postgres"
2679 [dbCredentials]
2680 database = "mydb"
2681 secretArn = "arn:aws:secretsmanager:..."
2682 resourceArn = "arn:aws:rds:..."
2683 "#,
2684 Path::new("test.toml"),
2685 )
2686 .unwrap_err();
2687 assert!(
2688 err.to_string().contains("driver = \"aws-data-api\""),
2689 "expected aws-data-api driver error, got: {err}"
2690 );
2691 }
2692
2693 #[test]
2694 fn aws_data_api_driver_requires_aws_credentials() {
2695 let err = Config::load_from_str(
2697 r#"
2698 dialect = "postgresql"
2699 driver = "aws-data-api"
2700 [dbCredentials]
2701 url = "postgres://localhost/db"
2702 "#,
2703 Path::new("test.toml"),
2704 )
2705 .unwrap_err();
2706 assert!(
2707 err.to_string()
2708 .contains("database, secretArn, and resourceArn"),
2709 "expected aws-data-api creds-shape error, got: {err}"
2710 );
2711 }
2712
2713 #[test]
2714 fn aws_data_api_rejected_for_non_postgres_dialect() {
2715 let err = Config::load_from_str(
2716 r#"
2717 dialect = "sqlite"
2718 driver = "aws-data-api"
2719 "#,
2720 Path::new("test.toml"),
2721 )
2722 .unwrap_err();
2723 assert!(
2724 err.to_string().contains("invalid for sqlite"),
2725 "expected dialect/driver mismatch error, got: {err}"
2726 );
2727 }
2728
2729 #[test]
2730 fn driver_valid_for_postgres_includes_aws_data_api() {
2731 let drivers = Driver::valid_for(Dialect::Postgresql);
2732 assert!(drivers.contains(&Driver::PostgresSync));
2733 assert!(drivers.contains(&Driver::TokioPostgres));
2734 assert!(drivers.contains(&Driver::AwsDataApi));
2735 assert!(!Driver::AwsDataApi.is_valid_for(Dialect::Sqlite));
2737 assert!(!Driver::AwsDataApi.is_valid_for(Dialect::Turso));
2738 }
2739
2740 #[test]
2741 fn credentials_debug_redacts_secrets() {
2742 let postgres = Credentials::Postgres(PostgresCreds::Host {
2743 host: "localhost".into(),
2744 port: 5432,
2745 user: Some("alice".into()),
2746 password: Some("super-secret-password".into()),
2747 database: "app".into(),
2748 ssl: PostgresSslMode::Require,
2749 });
2750 let turso = Credentials::Turso {
2751 url: "libsql://example.turso.io".into(),
2752 auth_token: Some("super-secret-token".into()),
2753 };
2754 let d1 = Credentials::D1 {
2755 account_id: "account".into(),
2756 database_id: "database".into(),
2757 token: "super-secret-d1-token".into(),
2758 };
2759 let postgres_url = Credentials::Postgres(PostgresCreds::Url(
2760 "postgres://alice:super-secret-url-password@localhost/app".into(),
2761 ));
2762
2763 let rendered = format!("{postgres:?} {turso:?} {d1:?} {postgres_url:?}");
2764 assert!(rendered.contains("[REDACTED]"));
2765 assert!(!rendered.contains("super-secret"));
2766 }
2767
2768 #[cfg(windows)]
2769 #[test]
2770 fn schema_files_accept_backslash_paths() {
2771 let tmp = TempDir::new().unwrap();
2772 let cfg_dir = tmp.path().join("cfg");
2773 fs::create_dir_all(&cfg_dir).unwrap();
2774
2775 let schema_path = cfg_dir.join("src").join("schema.rs");
2776 fs::create_dir_all(schema_path.parent().unwrap()).unwrap();
2777 fs::write(&schema_path, "#[allow(dead_code)]\npub struct X;").unwrap();
2778
2779 let schema_str = schema_path.to_string_lossy().replace('/', "\\");
2781 let schema_toml = schema_str.replace('\\', "\\\\");
2783 let cfg_path = cfg_dir.join("drizzle.config.toml");
2784 let cfg = Config::load_from_str(
2785 &format!(
2786 r#"
2787 dialect = "sqlite"
2788 schema = "{}"
2789 "#,
2790 schema_toml
2791 ),
2792 &cfg_path,
2793 )
2794 .unwrap();
2795
2796 let db = cfg.default_database().unwrap();
2797 let files = db.schema_files().unwrap();
2798 assert_eq!(files, vec![schema_path]);
2799 }
2800
2801 #[test]
2805 fn schema_resolution_rejects_any_missing_path_or_empty_pattern() {
2806 let tmp = TempDir::new().unwrap();
2807 let present = tmp.path().join("present.rs");
2808 fs::write(&present, "pub struct Present;").unwrap();
2809 let present = present.to_string_lossy().replace('\\', "/");
2810 let missing = tmp
2811 .path()
2812 .join("missing.rs")
2813 .to_string_lossy()
2814 .replace('\\', "/");
2815 let empty_glob = format!(
2816 "{}/nothing/*.rs",
2817 tmp.path().to_string_lossy().replace('\\', "/")
2818 );
2819
2820 assert_eq!(
2821 resolve_schema_patterns([present.as_str()]).unwrap().len(),
2822 1
2823 );
2824 assert!(matches!(
2825 resolve_schema_patterns([present.as_str(), missing.as_str()]),
2826 Err(Error::SchemaPathNotFound(path)) if path == missing
2827 ));
2828 assert!(matches!(
2829 resolve_schema_patterns([present.as_str(), empty_glob.as_str()]),
2830 Err(Error::SchemaPatternMatchedNothing(pattern)) if pattern == empty_glob
2831 ));
2832 assert!(matches!(
2833 resolve_schema_patterns(["src/[.rs"]),
2834 Err(Error::Glob(..))
2835 ));
2836 }
2837}