Skip to main content

drizzle_cli/
config.rs

1//! Configuration for Drizzle CLI
2//!
3//! Handles loading `drizzle.config.toml` with type-safe credentials.
4//! Supports both single-database (legacy) and multi-database configurations.
5//!
6//! This configuration format is designed to be compatible with drizzle-kit
7//! so TypeScript users can use the same config expectations.
8
9pub use drizzle_types::{Casing, ConfigValue, ConfigValueError};
10use schemars::JsonSchema;
11use serde::Deserialize;
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15pub const CONFIG_FILE: &str = "drizzle.config.toml";
16
17/// Casing mode for introspection (pull command)
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Deserialize, JsonSchema)]
19pub enum IntrospectCasing {
20    /// Convert database names to camelCase
21    #[default]
22    #[serde(rename = "camel")]
23    Camel,
24    /// Preserve original database names
25    #[serde(rename = "preserve")]
26    Preserve,
27}
28
29impl IntrospectCasing {
30    #[must_use]
31    pub const fn as_str(self) -> &'static str {
32        match self {
33            Self::Camel => "camel",
34            Self::Preserve => "preserve",
35        }
36    }
37}
38
39impl std::fmt::Display for IntrospectCasing {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        f.write_str(self.as_str())
42    }
43}
44
45impl std::str::FromStr for IntrospectCasing {
46    type Err = String;
47
48    fn from_str(s: &str) -> Result<Self, Self::Err> {
49        match s {
50            "camel" | "camelCase" => Ok(Self::Camel),
51            "preserve" => Ok(Self::Preserve),
52            _ => Err(format!(
53                "invalid introspect casing '{s}', expected 'camel' or 'preserve'"
54            )),
55        }
56    }
57}
58
59/// Introspection configuration
60#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
61pub struct IntrospectConfig {
62    /// Casing mode for introspected identifiers
63    #[serde(default)]
64    pub casing: IntrospectCasing,
65}
66
67// ============================================================================
68// Entities Filter (matching drizzle-kit)
69// ============================================================================
70
71/// Roles filter configuration
72///
73/// Can be either a boolean (true = include all, false = exclude all)
74/// or a detailed configuration with provider/include/exclude lists.
75#[derive(Debug, Clone, Deserialize, JsonSchema)]
76#[serde(untagged)]
77pub enum RolesFilter {
78    /// Simple boolean: true = include all user roles, false = exclude all
79    Bool(bool),
80    /// Detailed configuration
81    Config {
82        /// Provider preset (e.g., "supabase", "neon") - excludes provider-specific roles
83        #[serde(default)]
84        provider: Option<String>,
85        /// Explicit list of role names to include
86        #[serde(default)]
87        include: Option<Vec<String>>,
88        /// Explicit list of role names to exclude
89        #[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    /// Check if roles should be included at all
102    #[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    /// Check if a specific role should be included
111    #[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                // Check provider exclusions
121                if let Some(p) = provider
122                    && is_provider_role(p, role_name)
123                {
124                    return false;
125                }
126                // Check explicit exclude list
127                if let Some(excl) = exclude
128                    && excl.iter().any(|e| e == role_name)
129                {
130                    return false;
131                }
132                // Check explicit include list (if specified, only include those)
133                if let Some(incl) = include {
134                    return incl.iter().any(|i| i == role_name);
135                }
136                true
137            }
138        }
139    }
140}
141
142/// Check if a role belongs to a provider's built-in roles
143fn 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/// Entities filter configuration
173///
174/// Controls which database entities are included in push/pull operations.
175#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
176pub struct EntitiesFilter {
177    /// Roles filter (`PostgreSQL` only)
178    #[serde(default)]
179    pub roles: RolesFilter,
180}
181
182// ============================================================================
183// Extensions Filter (PostgreSQL only)
184// ============================================================================
185
186/// Known `PostgreSQL` extensions that can be filtered
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, JsonSchema)]
188#[serde(rename_all = "lowercase")]
189pub enum Extension {
190    /// `PostGIS` spatial extension
191    Postgis,
192}
193
194impl Extension {
195    #[must_use]
196    pub const fn as_str(self) -> &'static str {
197        match self {
198            Self::Postgis => "postgis",
199        }
200    }
201}
202
203impl std::fmt::Display for Extension {
204    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205        f.write_str(self.as_str())
206    }
207}
208
209// ============================================================================
210// Dialect
211// ============================================================================
212
213/// Database dialect
214#[derive(
215    Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, Deserialize, JsonSchema,
216)]
217#[serde(rename_all = "lowercase")]
218pub enum Dialect {
219    #[default]
220    Sqlite,
221    #[serde(alias = "postgres")]
222    Postgresql,
223    Mysql,
224    Turso,
225}
226
227impl Dialect {
228    pub const ALL: &'static [&'static str] = &["sqlite", "postgresql", "mysql", "turso"];
229
230    #[inline]
231    #[must_use]
232    pub const fn as_str(self) -> &'static str {
233        match self {
234            Self::Sqlite => "sqlite",
235            Self::Postgresql => "postgresql",
236            Self::Mysql => "mysql",
237            Self::Turso => "turso",
238        }
239    }
240
241    #[inline]
242    #[must_use]
243    pub const fn to_base(self) -> drizzle_types::Dialect {
244        match self {
245            Self::Sqlite | Self::Turso => drizzle_types::Dialect::SQLite,
246            Self::Postgresql => drizzle_types::Dialect::PostgreSQL,
247            Self::Mysql => drizzle_types::Dialect::MySQL,
248        }
249    }
250}
251
252impl std::fmt::Display for Dialect {
253    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254        f.write_str(self.as_str())
255    }
256}
257
258impl std::str::FromStr for Dialect {
259    type Err = String;
260
261    fn from_str(s: &str) -> Result<Self, Self::Err> {
262        match s {
263            "sqlite" => Ok(Self::Sqlite),
264            "postgresql" | "postgres" => Ok(Self::Postgresql),
265            "mysql" => Ok(Self::Mysql),
266            "turso" => Ok(Self::Turso),
267            _ => Err(format!(
268                "invalid dialect '{}', expected one of: {}",
269                s,
270                Self::ALL.join(", ")
271            )),
272        }
273    }
274}
275
276impl From<Dialect> for drizzle_types::Dialect {
277    #[inline]
278    fn from(d: Dialect) -> Self {
279        d.to_base()
280    }
281}
282
283// ============================================================================
284// Driver
285// ============================================================================
286
287/// Database driver for Rust database connections
288#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, JsonSchema)]
289#[serde(rename_all = "kebab-case")]
290pub enum Driver {
291    /// rusqlite - synchronous `SQLite` driver
292    Rusqlite,
293    /// libsql - `LibSQL` driver (local embedded)
294    Libsql,
295    /// turso - Turso cloud driver (remote)
296    Turso,
297    /// postgres-sync - synchronous `PostgreSQL` driver
298    PostgresSync,
299    /// tokio-postgres - async `PostgreSQL` driver
300    TokioPostgres,
301    /// mysql-sync - blocking MySQL driver
302    MysqlSync,
303    /// mysql-async - Tokio MySQL driver
304    MysqlAsync,
305    /// d1-http - Cloudflare D1 over the HTTP API
306    ///
307    /// Targets a remote D1 database via the Cloudflare REST API. Requires
308    /// `accountId`, `databaseId`, and `token` in `dbCredentials`. For deploying
309    /// to a Worker binding at runtime use the `d1` driver feature on the
310    /// drizzle crate itself — this CLI driver is for schema ops (generate /
311    /// push / pull / migrate) against a live D1 instance from your dev box.
312    D1Http,
313    /// durable-sqlite - Cloudflare Durable Objects `SQLite` storage
314    ///
315    /// DOs run `SQLite` embedded inside the Worker runtime. There's no remote
316    /// endpoint to push to from the CLI, so this driver is schema-only:
317    /// `generate` produces SQL migrations and a bundled `migrations.js` index
318    /// (like drizzle-kit's `bundle: true`) that the Worker imports at build
319    /// time to apply migrations inside `DurableObject::new()`.
320    DurableSqlite,
321    /// aws-data-api - AWS RDS Data API (Aurora Serverless `PostgreSQL`)
322    ///
323    /// Runs SQL through the AWS RDS Data API instead of a direct TCP
324    /// connection. Requires `database`, `secretArn` (AWS Secrets Manager ARN
325    /// holding the DB password), and `resourceArn` (Aurora cluster ARN) in
326    /// `dbCredentials`. The AWS region comes from the standard SDK chain
327    /// (env vars, `~/.aws/config`, EC2/ECS metadata) — drizzle-kit takes no
328    /// region field and we match that.
329    ///
330    /// At the Rust layer this would route through the `aws-sdk-rdsdata` crate,
331    /// which isn't yet wired into drizzle-rs — this driver is currently
332    /// recognized by the CLI for config parity, but operations return a
333    /// pointed `UnsupportedForDriver` error.
334    AwsDataApi,
335}
336
337impl Driver {
338    pub const ALL: &'static [&'static str] = &[
339        "rusqlite",
340        "libsql",
341        "turso",
342        "postgres-sync",
343        "tokio-postgres",
344        "mysql-sync",
345        "mysql-async",
346        "d1-http",
347        "durable-sqlite",
348        "aws-data-api",
349    ];
350
351    #[inline]
352    #[must_use]
353    pub const fn as_str(self) -> &'static str {
354        match self {
355            Self::Rusqlite => "rusqlite",
356            Self::Libsql => "libsql",
357            Self::Turso => "turso",
358            Self::PostgresSync => "postgres-sync",
359            Self::TokioPostgres => "tokio-postgres",
360            Self::MysqlSync => "mysql-sync",
361            Self::MysqlAsync => "mysql-async",
362            Self::D1Http => "d1-http",
363            Self::DurableSqlite => "durable-sqlite",
364            Self::AwsDataApi => "aws-data-api",
365        }
366    }
367
368    #[must_use]
369    pub const fn valid_for(dialect: Dialect) -> &'static [Self] {
370        match dialect {
371            // D1 and Durable Objects are both SQLite-dialect — they only differ
372            // in how you reach the database at runtime, so the generator/parser
373            // path is identical to plain rusqlite.
374            Dialect::Sqlite => &[Self::Rusqlite, Self::D1Http, Self::DurableSqlite],
375            Dialect::Turso => &[Self::Libsql, Self::Turso],
376            Dialect::Postgresql => &[Self::PostgresSync, Self::TokioPostgres, Self::AwsDataApi],
377            Dialect::Mysql => &[Self::MysqlSync, Self::MysqlAsync],
378        }
379    }
380
381    #[inline]
382    #[must_use]
383    pub const fn is_valid_for(self, dialect: Dialect) -> bool {
384        matches!(
385            (self, dialect),
386            (
387                Self::Rusqlite | Self::D1Http | Self::DurableSqlite,
388                Dialect::Sqlite
389            ) | (Self::Libsql | Self::Turso, Dialect::Turso)
390                | (
391                    Self::PostgresSync | Self::TokioPostgres | Self::AwsDataApi,
392                    Dialect::Postgresql
393                )
394                | (Self::MysqlSync | Self::MysqlAsync, Dialect::Mysql)
395        )
396    }
397
398    /// True for drivers that only make sense as schema/codegen targets — i.e.
399    /// drivers where the CLI has no way to connect to a live DB from the dev
400    /// machine (e.g. Durable Objects `SQLite` runs inside the Workers runtime).
401    #[inline]
402    #[must_use]
403    pub const fn is_codegen_only(self) -> bool {
404        matches!(self, Self::DurableSqlite)
405    }
406}
407
408impl std::fmt::Display for Driver {
409    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
410        f.write_str(self.as_str())
411    }
412}
413
414impl std::str::FromStr for Driver {
415    type Err = String;
416
417    fn from_str(s: &str) -> Result<Self, Self::Err> {
418        match s {
419            "rusqlite" => Ok(Self::Rusqlite),
420            "libsql" => Ok(Self::Libsql),
421            "turso" => Ok(Self::Turso),
422            "postgres-sync" => Ok(Self::PostgresSync),
423            "tokio-postgres" => Ok(Self::TokioPostgres),
424            "mysql-sync" => Ok(Self::MysqlSync),
425            "mysql-async" => Ok(Self::MysqlAsync),
426            "d1-http" => Ok(Self::D1Http),
427            "durable-sqlite" => Ok(Self::DurableSqlite),
428            "aws-data-api" => Ok(Self::AwsDataApi),
429            _ => Err(format!(
430                "invalid driver '{}', expected one of: {}",
431                s,
432                Self::ALL.join(", ")
433            )),
434        }
435    }
436}
437
438impl std::str::FromStr for Extension {
439    type Err = String;
440
441    fn from_str(s: &str) -> Result<Self, Self::Err> {
442        match s {
443            "postgis" => Ok(Self::Postgis),
444            _ => Err(format!(
445                "invalid extension filter '{s}', expected 'postgis'"
446            )),
447        }
448    }
449}
450
451// ============================================================================
452// Credentials
453// ============================================================================
454
455/// Database credentials - validated and typed
456#[derive(Clone)]
457pub enum Credentials {
458    /// Local `SQLite` file
459    Sqlite { path: Box<str> },
460
461    /// Turso/LibSQL
462    Turso {
463        url: Box<str>,
464        auth_token: Option<Box<str>>,
465    },
466
467    /// `PostgreSQL`
468    Postgres(PostgresCreds),
469
470    /// MySQL
471    MySQL(MySQLCreds),
472
473    /// Cloudflare D1 over the HTTP API.
474    ///
475    /// Used by the CLI to hit the Cloudflare REST endpoint for schema ops
476    /// (push/pull/migrate). The drizzle runtime itself uses the worker
477    /// `D1Database` binding — not these credentials.
478    D1 {
479        account_id: Box<str>,
480        database_id: Box<str>,
481        token: Box<str>,
482    },
483
484    /// AWS RDS Data API (Aurora Serverless `PostgreSQL`).
485    ///
486    /// The region isn't stored here — the AWS SDK pulls it from the standard
487    /// credential chain (env vars, `~/.aws/config`, instance metadata). This
488    /// matches drizzle-kit's TypeScript config exactly.
489    AwsDataApi {
490        database: Box<str>,
491        secret_arn: Box<str>,
492        resource_arn: Box<str>,
493    },
494}
495
496/// `PostgreSQL` credentials
497#[derive(Clone)]
498pub enum PostgresCreds {
499    Url(Box<str>),
500    Host {
501        host: Box<str>,
502        port: u16,
503        user: Option<Box<str>>,
504        password: Option<Box<str>>,
505        database: Box<str>,
506        ssl: PostgresSslMode,
507    },
508}
509
510/// MySQL credentials. Both Rust adapters consume the same resolved shape;
511/// [`Driver`] selects the concrete connection effect later.
512#[derive(Clone)]
513pub enum MySQLCreds {
514    Url(Box<str>),
515    Host {
516        host: Box<str>,
517        port: u16,
518        user: Option<Box<str>>,
519        password: Option<Box<str>>,
520        database: Box<str>,
521        ssl: MySQLSslMode,
522    },
523}
524
525impl std::fmt::Debug for Credentials {
526    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
527        match self {
528            Self::Sqlite { path } => f.debug_struct("Sqlite").field("path", path).finish(),
529            Self::Turso { url, auth_token } => f
530                .debug_struct("Turso")
531                .field("url", url)
532                .field("auth_token", &auth_token.as_ref().map(|_| "[REDACTED]"))
533                .finish(),
534            Self::Postgres(credentials) => f.debug_tuple("Postgres").field(credentials).finish(),
535            Self::MySQL(credentials) => f.debug_tuple("MySQL").field(credentials).finish(),
536            Self::D1 {
537                account_id,
538                database_id,
539                token,
540            } => f
541                .debug_struct("D1")
542                .field("account_id", account_id)
543                .field("database_id", database_id)
544                .field("token", &(!token.is_empty()).then_some("[REDACTED]"))
545                .finish(),
546            Self::AwsDataApi {
547                database,
548                secret_arn,
549                resource_arn,
550            } => f
551                .debug_struct("AwsDataApi")
552                .field("database", database)
553                .field("secret_arn", secret_arn)
554                .field("resource_arn", resource_arn)
555                .finish(),
556        }
557    }
558}
559
560impl std::fmt::Debug for PostgresCreds {
561    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
562        match self {
563            Self::Url(_) => f.debug_tuple("Url").field(&"[REDACTED]").finish(),
564            Self::Host {
565                host,
566                port,
567                user,
568                password,
569                database,
570                ssl,
571            } => f
572                .debug_struct("Host")
573                .field("host", host)
574                .field("port", port)
575                .field("user", user)
576                .field("password", &password.as_ref().map(|_| "[REDACTED]"))
577                .field("database", database)
578                .field("ssl", ssl)
579                .finish(),
580        }
581    }
582}
583
584impl std::fmt::Debug for MySQLCreds {
585    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
586        match self {
587            Self::Url(_) => f.debug_tuple("Url").field(&"[REDACTED]").finish(),
588            Self::Host {
589                host,
590                port,
591                user,
592                password,
593                database,
594                ssl,
595            } => f
596                .debug_struct("Host")
597                .field("host", host)
598                .field("port", port)
599                .field("user", user)
600                .field("password", &password.as_ref().map(|_| "[REDACTED]"))
601                .field("database", database)
602                .field("ssl", ssl)
603                .finish(),
604        }
605    }
606}
607
608#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
609pub enum PostgresSslMode {
610    #[default]
611    Disable,
612    Allow,
613    Prefer,
614    Require,
615    VerifyCa,
616    VerifyFull,
617}
618
619/// MySQL TLS policy accepted by URL/host CLI configuration.
620#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
621pub enum MySQLSslMode {
622    #[default]
623    Disable,
624    Required,
625    VerifyCa,
626    VerifyIdentity,
627}
628
629impl MySQLSslMode {
630    pub(crate) fn parse(value: &str) -> Result<Self, String> {
631        match value.trim().to_ascii_lowercase().as_str() {
632            "false" | "0" | "no" | "off" | "disable" | "disabled" => Ok(Self::Disable),
633            "true" | "1" | "yes" | "on" | "require" | "required" => Ok(Self::Required),
634            "verify-ca" => Ok(Self::VerifyCa),
635            "verify-identity" | "verify-full" => Ok(Self::VerifyIdentity),
636            _ => Err(format!("invalid MySQL SSL mode `{value}`")),
637        }
638    }
639}
640
641impl PostgresSslMode {
642    pub(crate) fn parse(value: &str) -> Result<Self, String> {
643        match value.trim().to_ascii_lowercase().as_str() {
644            "false" | "0" | "no" | "off" | "disable" => Ok(Self::Disable),
645            "allow" => Ok(Self::Allow),
646            "prefer" => Ok(Self::Prefer),
647            "true" | "1" | "yes" | "on" | "require" => Ok(Self::Require),
648            "verify-ca" => Ok(Self::VerifyCa),
649            "verify-full" => Ok(Self::VerifyFull),
650            _ => Err(format!("invalid PostgreSQL SSL mode `{value}`")),
651        }
652    }
653}
654
655#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
656pub struct PostgresConnectionConfig {
657    pub config: tokio_postgres::Config,
658    pub ssl: PostgresSslMode,
659}
660
661#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
662impl PostgresCreds {
663    pub fn connection_config(&self) -> Result<PostgresConnectionConfig, String> {
664        match self {
665            Self::Url(url) => {
666                let config = url
667                    .parse::<tokio_postgres::Config>()
668                    .map_err(|error| error.to_string())?;
669                let ssl = match config.get_ssl_mode() {
670                    tokio_postgres::config::SslMode::Disable => PostgresSslMode::Disable,
671                    tokio_postgres::config::SslMode::Prefer => PostgresSslMode::Prefer,
672                    tokio_postgres::config::SslMode::Require => PostgresSslMode::Require,
673                    _ => PostgresSslMode::Require,
674                };
675                Ok(PostgresConnectionConfig { config, ssl })
676            }
677            Self::Host {
678                host,
679                port,
680                user,
681                password,
682                database,
683                ssl,
684            } => {
685                let mut config = tokio_postgres::Config::new();
686                config
687                    .host(host.as_ref())
688                    .port(*port)
689                    .dbname(database.as_ref());
690                if let Some(user) = user {
691                    config.user(user.as_ref());
692                }
693                if let Some(password) = password {
694                    config.password(password.as_bytes());
695                }
696                config.ssl_mode(match ssl {
697                    PostgresSslMode::Disable => tokio_postgres::config::SslMode::Disable,
698                    PostgresSslMode::Allow | PostgresSslMode::Prefer => {
699                        tokio_postgres::config::SslMode::Prefer
700                    }
701                    PostgresSslMode::Require
702                    | PostgresSslMode::VerifyCa
703                    | PostgresSslMode::VerifyFull => tokio_postgres::config::SslMode::Require,
704                });
705                Ok(PostgresConnectionConfig { config, ssl: *ssl })
706            }
707        }
708    }
709}
710
711// ============================================================================
712// Schema path(s)
713// ============================================================================
714
715/// Schema path(s)
716#[derive(Debug, Clone, Deserialize, JsonSchema)]
717#[serde(untagged)]
718pub enum Schema {
719    One(String),
720    Many(Vec<String>),
721}
722
723impl Default for Schema {
724    fn default() -> Self {
725        Self::One("src/schema.rs".into())
726    }
727}
728
729impl Schema {
730    pub fn iter(&self) -> impl Iterator<Item = &str> {
731        match self {
732            Self::One(s) => std::slice::from_ref(s).iter().map(String::as_str),
733            Self::Many(v) => v.iter().map(String::as_str),
734        }
735    }
736}
737
738/// Filter (single or multiple values)
739#[derive(Debug, Clone, Deserialize, JsonSchema)]
740#[serde(untagged)]
741pub enum Filter {
742    One(String),
743    Many(Vec<String>),
744}
745
746impl Filter {
747    pub fn iter(&self) -> impl Iterator<Item = &str> {
748        match self {
749            Self::One(s) => std::slice::from_ref(s).iter().map(String::as_str),
750            Self::Many(v) => v.iter().map(String::as_str),
751        }
752    }
753}
754
755/// Migration options
756#[derive(Debug, Clone, Deserialize, JsonSchema)]
757pub struct MigrationsOpts {
758    pub table: Option<String>,
759    pub schema: Option<String>,
760    pub prefix: Option<MigrationPrefix>,
761    /// Emit a `migrations.js` index at the root of the migrations output folder.
762    ///
763    /// Matches drizzle-kit's `bundle: true` behavior. The file statically
764    /// `import`s each `migration.sql` so JS bundlers (Metro for Expo/React
765    /// Native, Cloudflare Workers for Durable Objects `SQLite`) can embed the
766    /// SQL text at build time. Harmless for Rust-only consumers.
767    #[serde(default)]
768    pub bundle: Option<bool>,
769}
770
771#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)]
772#[serde(rename_all = "lowercase")]
773pub enum MigrationPrefix {
774    Index,
775    Timestamp,
776    Supabase,
777    Unix,
778    None,
779}
780
781// ============================================================================
782// Raw credentials (serde parsing helper)
783// ============================================================================
784
785#[derive(Debug, Clone, Deserialize, JsonSchema)]
786#[serde(untagged)]
787enum RawCreds {
788    /// Cloudflare D1 HTTP API credentials — `{ accountId, databaseId, token }`.
789    ///
790    /// Listed before the more generic `Url` / `Host` variants so that serde's
791    /// untagged matching prefers the fully-specified D1 shape when all three
792    /// fields are present. (Untagged enums try variants top-to-bottom and pick
793    /// the first that deserializes cleanly.)
794    D1 {
795        #[serde(rename = "accountId")]
796        account_id: ConfigValue,
797        #[serde(rename = "databaseId")]
798        database_id: ConfigValue,
799        token: ConfigValue,
800    },
801    /// AWS RDS Data API credentials — `{ database, secretArn, resourceArn }`.
802    ///
803    /// Also listed before `Url`/`Host` so the fully-specified shape wins in the
804    /// untagged match. Note that `database` is also a field name on the Host
805    /// shape, but the combination with `secretArn` + `resourceArn` uniquely
806    /// identifies this variant.
807    AwsDataApi {
808        database: ConfigValue,
809        #[serde(rename = "secretArn")]
810        secret_arn: ConfigValue,
811        #[serde(rename = "resourceArn")]
812        resource_arn: ConfigValue,
813    },
814    Url {
815        url: ConfigValue,
816        #[serde(default, rename = "authToken")]
817        auth_token: Option<ConfigValue>,
818    },
819    Host {
820        host: ConfigValue,
821        #[serde(default)]
822        port: Option<u16>,
823        #[serde(default)]
824        user: Option<ConfigValue>,
825        #[serde(default)]
826        password: Option<ConfigValue>,
827        database: ConfigValue,
828        #[serde(default)]
829        ssl: Option<SslVal>,
830    },
831}
832
833#[derive(Debug, Clone, Deserialize, JsonSchema)]
834#[serde(untagged)]
835enum SslVal {
836    Bool(bool),
837    Str(String),
838}
839
840impl SslVal {
841    fn postgres_mode(&self) -> Result<PostgresSslMode, String> {
842        match self {
843            Self::Bool(false) => Ok(PostgresSslMode::Disable),
844            Self::Bool(true) => Ok(PostgresSslMode::Require),
845            Self::Str(value) => PostgresSslMode::parse(value),
846        }
847    }
848
849    fn mysql_mode(&self) -> Result<MySQLSslMode, String> {
850        match self {
851            Self::Bool(false) => Ok(MySQLSslMode::Disable),
852            Self::Bool(true) => Ok(MySQLSslMode::Required),
853            Self::Str(value) => MySQLSslMode::parse(value),
854        }
855    }
856}
857
858// ============================================================================
859// DatabaseConfig - Per-database configuration
860// ============================================================================
861
862/// Configuration for a single database
863///
864/// This structure matches drizzle-kit's config format for compatibility.
865#[derive(Debug, Clone, Deserialize, JsonSchema)]
866#[serde(rename_all = "camelCase")]
867pub struct DatabaseConfig {
868    /// Database dialect (required)
869    pub dialect: Dialect,
870
871    /// Path(s) to schema file(s) - supports glob patterns
872    #[serde(default)]
873    pub schema: Schema,
874
875    /// Output directory for migrations (default: "./drizzle")
876    #[serde(default = "default_out")]
877    pub out: PathBuf,
878
879    /// Whether to use SQL breakpoints in migrations (default: true)
880    #[serde(default = "yes")]
881    pub breakpoints: bool,
882
883    /// Database driver for Rust connections
884    #[serde(default)]
885    pub driver: Option<Driver>,
886
887    /// Database credentials
888    #[serde(default)]
889    db_credentials: Option<RawCreds>,
890
891    /// Table name filter (glob patterns supported)
892    #[serde(default)]
893    pub tables_filter: Option<Filter>,
894
895    /// Schema name filter (`PostgreSQL` only)
896    #[serde(default)]
897    pub schema_filter: Option<Filter>,
898
899    /// Extensions filter (`PostgreSQL` only, e.g., `["postgis"]`)
900    #[serde(default)]
901    pub extensions_filters: Option<Vec<Extension>>,
902
903    /// Entities filter (roles, etc.)
904    #[serde(default)]
905    pub entities: Option<EntitiesFilter>,
906
907    /// Casing mode for generated code
908    #[serde(default)]
909    pub casing: Option<Casing>,
910
911    /// Introspection configuration
912    #[serde(default)]
913    pub introspect: Option<IntrospectConfig>,
914
915    /// Verbose output
916    #[serde(default)]
917    pub verbose: bool,
918
919    /// Migration table configuration
920    #[serde(default)]
921    pub migrations: Option<MigrationsOpts>,
922}
923
924fn default_out() -> PathBuf {
925    PathBuf::from("./drizzle")
926}
927
928const fn yes() -> bool {
929    true
930}
931
932impl DatabaseConfig {
933    fn normalize_paths(&mut self, base_dir: &Path) {
934        // Resolve `out` relative to the config file directory for predictable behavior,
935        // especially when `--config` points at a file outside the current working directory.
936        if self.out.is_relative() {
937            self.out = base_dir.join(&self.out);
938        }
939
940        // Normalize schema patterns:
941        // - Resolve relative patterns relative to config dir
942        // - Use forward slashes to avoid glob escaping issues on Windows
943        let base = base_dir.to_string_lossy().replace('\\', "/");
944        let base = base.trim_end_matches('/').to_string();
945
946        let normalize_one = |p: &str| -> String {
947            let p_trim = p.trim();
948            let is_abs = Path::new(p_trim).is_absolute() || p_trim.starts_with("\\\\");
949            let joined = if is_abs || base.is_empty() || base == "." {
950                p_trim.to_string()
951            } else {
952                format!("{base}/{p_trim}")
953            };
954            joined.replace('\\', "/")
955        };
956
957        match &mut self.schema {
958            Schema::One(p) => *p = normalize_one(p),
959            Schema::Many(v) => {
960                for p in v.iter_mut() {
961                    *p = normalize_one(p);
962                }
963            }
964        }
965    }
966
967    fn validate(&self, name: &str) -> Result<(), Error> {
968        // Check driver compatibility
969        if let Some(d) = self.driver
970            && !d.is_valid_for(self.dialect)
971        {
972            return Err(Error::InvalidDriver {
973                driver: d,
974                dialect: self.dialect,
975            });
976        }
977
978        // Validate credentials if present
979        if let Some(ref raw) = self.db_credentials {
980            self.validate_creds(raw, name)?;
981        }
982
983        // PostgreSQL-only settings
984        if self.dialect != Dialect::Postgresql {
985            if self.schema_filter.is_some() {
986                return Err(Error::InvalidConfig(
987                    "schemaFilter is only supported for dialect = \"postgresql\"".into(),
988                ));
989            }
990            if self.extensions_filters.is_some() {
991                return Err(Error::InvalidConfig(
992                    "extensionsFilters is only supported for dialect = \"postgresql\"".into(),
993                ));
994            }
995            if self.entities.is_some() {
996                return Err(Error::InvalidConfig(
997                    "entities filter is only supported for dialect = \"postgresql\"".into(),
998                ));
999            }
1000        }
1001
1002        Ok(())
1003    }
1004
1005    fn validate_creds(&self, raw: &RawCreds, _name: &str) -> Result<(), Error> {
1006        let err = |msg: &str| Error::InvalidCredentials(msg.into());
1007
1008        // Enforce dialect/shape pairing. Without this, serde can parse a "host" form for
1009        // any dialect, and later `credentials()` would silently return None.
1010        match (self.dialect, raw) {
1011            (
1012                Dialect::Postgresql | Dialect::Mysql,
1013                RawCreds::Host { .. } | RawCreds::Url { .. },
1014            ) => {}
1015            (_, RawCreds::Host { .. }) => {
1016                return Err(err(
1017                    "host-based dbCredentials are only supported for dialect = \"postgresql\" or \"mysql\"",
1018                ));
1019            }
1020            _ => {}
1021        }
1022
1023        // D1-specific shape requires dialect=sqlite AND driver=d1-http. Paired
1024        // together so users can't accidentally point a rusqlite driver at D1
1025        // credentials (or vice versa).
1026        if let RawCreds::D1 { .. } = raw {
1027            if self.dialect != Dialect::Sqlite {
1028                return Err(err(
1029                    "D1 dbCredentials (accountId/databaseId/token) require dialect = \"sqlite\"",
1030                ));
1031            }
1032            if self.driver != Some(Driver::D1Http) {
1033                return Err(err(
1034                    "D1 dbCredentials (accountId/databaseId/token) require driver = \"d1-http\"",
1035                ));
1036            }
1037        }
1038
1039        // Conversely, if the user picked driver = d1-http but didn't supply the
1040        // D1 shape, flag it early — otherwise `credentials()` would silently
1041        // return None and the CLI would fail much later with a confusing error.
1042        if self.driver == Some(Driver::D1Http) && !matches!(raw, RawCreds::D1 { .. }) {
1043            return Err(err(
1044                "driver = \"d1-http\" requires dbCredentials with accountId, databaseId, and token",
1045            ));
1046        }
1047
1048        // AWS Data API shape requires dialect=postgresql AND driver=aws-data-api.
1049        // Matches drizzle-kit's shape exactly: { database, secretArn, resourceArn }.
1050        // Region isn't part of the config — the AWS SDK resolves it from env.
1051        if let RawCreds::AwsDataApi { .. } = raw {
1052            if self.dialect != Dialect::Postgresql {
1053                return Err(err(
1054                    "AWS Data API dbCredentials (database/secretArn/resourceArn) require dialect = \"postgresql\"",
1055                ));
1056            }
1057            if self.driver != Some(Driver::AwsDataApi) {
1058                return Err(err(
1059                    "AWS Data API dbCredentials (database/secretArn/resourceArn) require driver = \"aws-data-api\"",
1060                ));
1061            }
1062        }
1063
1064        // And the inverse — driver=aws-data-api must use the AwsDataApi shape.
1065        if self.driver == Some(Driver::AwsDataApi) && !matches!(raw, RawCreds::AwsDataApi { .. }) {
1066            return Err(err(
1067                "driver = \"aws-data-api\" requires dbCredentials with database, secretArn, and resourceArn",
1068            ));
1069        }
1070
1071        // Dialect-specific checks (only for direct values, not env var references)
1072        match (self.dialect, raw) {
1073            (
1074                Dialect::Sqlite,
1075                RawCreds::Url {
1076                    auth_token: Some(_),
1077                    ..
1078                },
1079            ) => Err(err(
1080                "SQLite doesn't support authToken (use dialect = \"turso\")",
1081            )),
1082            (
1083                Dialect::Mysql,
1084                RawCreds::Url {
1085                    auth_token: Some(_),
1086                    ..
1087                },
1088            ) => Err(err("MySQL doesn't support authToken")),
1089            (
1090                Dialect::Sqlite,
1091                RawCreds::Url {
1092                    url: ConfigValue::Inline(url),
1093                    ..
1094                },
1095            ) if url.starts_with("libsql://") => Err(err(
1096                "libsql:// URLs require dialect = \"turso\" (for local SQLite files, use ./path.db)",
1097            )),
1098            (
1099                Dialect::Sqlite,
1100                RawCreds::Url {
1101                    url: ConfigValue::Inline(url),
1102                    ..
1103                },
1104            ) if url.starts_with("http://")
1105                || url.starts_with("https://")
1106                || url.starts_with("postgres://")
1107                || url.starts_with("postgresql://") =>
1108            {
1109                Err(err(
1110                    "SQLite dbCredentials.url must be a local file path (not an http(s)/postgres URL)",
1111                ))
1112            }
1113            (
1114                Dialect::Turso,
1115                RawCreds::Url {
1116                    url: ConfigValue::Inline(url),
1117                    ..
1118                },
1119            ) if !url.starts_with("libsql://") && !url.starts_with("http") => {
1120                Err(err("Turso URL must start with libsql:// or http(s)://"))
1121            }
1122            (
1123                Dialect::Postgresql,
1124                RawCreds::Url {
1125                    url: ConfigValue::Inline(url),
1126                    ..
1127                },
1128            ) if !url.starts_with("postgres") => {
1129                Err(err("PostgreSQL URL must start with postgres://"))
1130            }
1131            (Dialect::Mysql, RawCreds::Host { ssl: Some(ssl), .. }) => ssl
1132                .mysql_mode()
1133                .map(|_| ())
1134                .map_err(Error::InvalidCredentials),
1135            (
1136                Dialect::Mysql,
1137                RawCreds::Url {
1138                    url: ConfigValue::Inline(url),
1139                    ..
1140                },
1141            ) if !url.starts_with("mysql://") => Err(err("MySQL URL must start with mysql://")),
1142            _ => Ok(()),
1143        }
1144    }
1145
1146    /// Get typed credentials, resolving any environment variable references.
1147    ///
1148    /// # Errors
1149    ///
1150    /// Returns [`Error`] if a referenced environment variable is missing or
1151    /// invalid, or if the credentials block does not match the configured
1152    /// dialect.
1153    pub fn credentials(&self) -> Result<Option<Credentials>, Error> {
1154        let Some(raw) = self.db_credentials.as_ref() else {
1155            return Ok(None);
1156        };
1157
1158        // Helper to resolve an optional ConfigValue
1159        let resolve_opt = |opt: &Option<ConfigValue>| -> Result<Option<Box<str>>, Error> {
1160            match opt.as_ref() {
1161                None => Ok(None),
1162                Some(e) => Ok(Some(e.resolve()?.into_boxed_str())),
1163            }
1164        };
1165
1166        let creds = match (self.dialect, raw) {
1167            // Cloudflare D1 HTTP — only valid with dialect=sqlite (enforced by
1168            // validate_creds). Keeps the driver field out of this arm since
1169            // validate_creds already guaranteed driver = d1-http.
1170            (
1171                Dialect::Sqlite,
1172                RawCreds::D1 {
1173                    account_id,
1174                    database_id,
1175                    token,
1176                },
1177            ) => Credentials::D1 {
1178                account_id: account_id.resolve()?.into_boxed_str(),
1179                database_id: database_id.resolve()?.into_boxed_str(),
1180                token: token.resolve()?.into_boxed_str(),
1181            },
1182            // AWS RDS Data API — only valid with dialect=postgresql (enforced
1183            // by validate_creds).
1184            (
1185                Dialect::Postgresql,
1186                RawCreds::AwsDataApi {
1187                    database,
1188                    secret_arn,
1189                    resource_arn,
1190                },
1191            ) => Credentials::AwsDataApi {
1192                database: database.resolve()?.into_boxed_str(),
1193                secret_arn: secret_arn.resolve()?.into_boxed_str(),
1194                resource_arn: resource_arn.resolve()?.into_boxed_str(),
1195            },
1196            // SQLite
1197            (Dialect::Sqlite, RawCreds::Url { url, .. }) => Credentials::Sqlite {
1198                path: url.resolve()?.into_boxed_str(),
1199            },
1200            // Turso
1201            (Dialect::Turso, RawCreds::Url { url, auth_token }) => Credentials::Turso {
1202                url: url.resolve()?.into_boxed_str(),
1203                auth_token: resolve_opt(auth_token)?,
1204            },
1205            // PostgreSQL URL
1206            (Dialect::Postgresql, RawCreds::Url { url, .. }) => {
1207                Credentials::Postgres(PostgresCreds::Url(url.resolve()?.into_boxed_str()))
1208            }
1209            // MySQL URL
1210            (
1211                Dialect::Mysql,
1212                RawCreds::Url {
1213                    url,
1214                    auth_token: None,
1215                },
1216            ) => Credentials::MySQL(MySQLCreds::Url(url.resolve()?.into_boxed_str())),
1217            // PostgreSQL Host
1218            (
1219                Dialect::Postgresql,
1220                RawCreds::Host {
1221                    host,
1222                    port,
1223                    user,
1224                    password,
1225                    database,
1226                    ssl,
1227                },
1228            ) => Credentials::Postgres(PostgresCreds::Host {
1229                host: host.resolve()?.into_boxed_str(),
1230                port: port.unwrap_or(5432),
1231                user: resolve_opt(user)?,
1232                password: resolve_opt(password)?,
1233                database: database.resolve()?.into_boxed_str(),
1234                ssl: ssl
1235                    .as_ref()
1236                    .map_or(Ok(PostgresSslMode::Disable), SslVal::postgres_mode)
1237                    .map_err(Error::InvalidCredentials)?,
1238            }),
1239            // MySQL Host
1240            (
1241                Dialect::Mysql,
1242                RawCreds::Host {
1243                    host,
1244                    port,
1245                    user,
1246                    password,
1247                    database,
1248                    ssl,
1249                },
1250            ) => Credentials::MySQL(MySQLCreds::Host {
1251                host: host.resolve()?.into_boxed_str(),
1252                port: port.unwrap_or(3306),
1253                user: resolve_opt(user)?,
1254                password: resolve_opt(password)?,
1255                database: database.resolve()?.into_boxed_str(),
1256                ssl: ssl
1257                    .as_ref()
1258                    .map_or(Ok(MySQLSslMode::Disable), SslVal::mysql_mode)
1259                    .map_err(Error::InvalidCredentials)?,
1260            }),
1261            _ => return Ok(None),
1262        };
1263
1264        Ok(Some(creds))
1265    }
1266
1267    /// Migrations output directory
1268    #[inline]
1269    #[must_use]
1270    pub fn migrations_dir(&self) -> &Path {
1271        &self.out
1272    }
1273
1274    /// Meta directory (for journal)
1275    #[inline]
1276    #[must_use]
1277    pub fn meta_dir(&self) -> PathBuf {
1278        self.out.join("meta")
1279    }
1280
1281    /// Journal file path
1282    #[inline]
1283    #[must_use]
1284    pub fn journal_path(&self) -> PathBuf {
1285        self.meta_dir().join("_journal.json")
1286    }
1287
1288    /// Schema paths display string
1289    #[must_use]
1290    pub fn schema_display(&self) -> String {
1291        match &self.schema {
1292            Schema::One(s) => s.clone(),
1293            Schema::Many(v) => v.join(", "),
1294        }
1295    }
1296
1297    /// Resolve schema files (with glob support).
1298    ///
1299    /// # Errors
1300    ///
1301    /// Returns [`Error`] if a listed path is missing, if a glob pattern is
1302    /// invalid, fails to expand or matches no file (see
1303    /// [`resolve_schema_patterns`]), or if nothing is listed at all.
1304    pub fn schema_files(&self) -> Result<Vec<PathBuf>, Error> {
1305        let files = resolve_schema_patterns(self.schema.iter())?;
1306        if files.is_empty() {
1307            return Err(Error::NoSchemaFiles(self.schema_display()));
1308        }
1309        Ok(files)
1310    }
1311
1312    /// Get effective casing mode (default: camelCase)
1313    #[inline]
1314    #[must_use]
1315    pub fn effective_casing(&self) -> Casing {
1316        self.casing.unwrap_or_default()
1317    }
1318
1319    /// Get effective introspect casing mode (default: camel)
1320    #[inline]
1321    #[must_use]
1322    pub fn effective_introspect_casing(&self) -> IntrospectCasing {
1323        self.introspect
1324            .as_ref()
1325            .map(|i| i.casing)
1326            .unwrap_or_default()
1327    }
1328
1329    /// Get entities filter (default: empty)
1330    #[inline]
1331    #[must_use]
1332    pub fn effective_entities(&self) -> EntitiesFilter {
1333        self.entities.clone().unwrap_or_default()
1334    }
1335
1336    /// Check if a role should be included based on entities filter
1337    #[must_use]
1338    pub fn should_include_role(&self, role_name: &str) -> bool {
1339        self.entities
1340            .as_ref()
1341            .is_some_and(|e| e.roles.should_include(role_name))
1342    }
1343
1344    /// Check if roles are enabled in entities filter
1345    #[must_use]
1346    pub fn roles_enabled(&self) -> bool {
1347        self.entities.as_ref().is_some_and(|e| e.roles.is_enabled())
1348    }
1349
1350    /// Get extensions filters (`PostgreSQL` only)
1351    #[must_use]
1352    pub fn extensions(&self) -> &[Extension] {
1353        self.extensions_filters.as_deref().unwrap_or(&[])
1354    }
1355
1356    /// Check if an extension is in the filter list
1357    #[must_use]
1358    pub fn has_extension(&self, ext: Extension) -> bool {
1359        self.extensions_filters
1360            .as_ref()
1361            .is_some_and(|v| v.contains(&ext))
1362    }
1363
1364    /// Get migration table name (default: __`drizzle_migrations`)
1365    #[must_use]
1366    pub fn migrations_table(&self) -> &str {
1367        self.migrations
1368            .as_ref()
1369            .and_then(|m| m.table.as_deref())
1370            .unwrap_or("__drizzle_migrations")
1371    }
1372
1373    /// Get migration schema (`PostgreSQL` only, default: drizzle)
1374    #[must_use]
1375    pub fn migrations_schema(&self) -> &str {
1376        self.migrations
1377            .as_ref()
1378            .and_then(|m| m.schema.as_deref())
1379            .unwrap_or("drizzle")
1380    }
1381
1382    /// Should a bundled `migrations.js` index be emitted alongside `migration.sql`?
1383    ///
1384    /// Resolution order:
1385    /// 1. Explicit `[migrations] bundle = true/false` in the config wins.
1386    /// 2. Otherwise, auto-enable for `driver = "durable-sqlite"` since Durable
1387    ///    Objects need the JS index to import migrations at Worker build time
1388    ///    (there's no other way to ship SQL into a DO).
1389    /// 3. Otherwise, default to `false`.
1390    #[must_use]
1391    pub fn bundle_enabled(&self) -> bool {
1392        if let Some(explicit) = self.migrations.as_ref().and_then(|m| m.bundle) {
1393            return explicit;
1394        }
1395        matches!(self.driver, Some(Driver::DurableSqlite))
1396    }
1397}
1398
1399// ============================================================================
1400// Main Configuration - Wrapper for single/multi-database modes
1401// ============================================================================
1402
1403/// Internal format for multi-database config
1404#[derive(Debug, Clone, Deserialize)]
1405struct MultiDbConfig {
1406    databases: HashMap<String, DatabaseConfig>,
1407}
1408
1409/// Main configuration structure
1410///
1411/// Supports both single-database (legacy) and multi-database configurations:
1412///
1413/// Single database:
1414/// ```toml
1415/// dialect = "sqlite"
1416/// [dbCredentials]
1417/// url = "./dev.db"
1418/// ```
1419///
1420/// Multiple databases:
1421/// ```toml
1422/// [databases.dev]
1423/// dialect = "sqlite"
1424/// [databases.dev.dbCredentials]
1425/// url = "./dev.db"
1426///
1427/// [databases.prod]
1428/// dialect = "postgresql"
1429/// [databases.prod.dbCredentials]
1430/// url = { env = "DATABASE_URL" }
1431/// ```
1432#[derive(Debug, Clone)]
1433pub struct Config {
1434    /// Named database configurations
1435    databases: HashMap<String, DatabaseConfig>,
1436    /// Whether this is a single-database config (for backwards compat)
1437    is_single: bool,
1438}
1439
1440/// Default database name for single-database configs
1441pub const DEFAULT_DB: &str = "default";
1442
1443impl Config {
1444    /// Load from default config file.
1445    ///
1446    /// # Errors
1447    ///
1448    /// Returns [`Error`] if the default config file cannot be read, if it
1449    /// fails to parse as JSON, or if validation of the parsed config fails.
1450    pub fn load() -> Result<Self, Error> {
1451        Self::load_from(Path::new(CONFIG_FILE))
1452    }
1453
1454    /// Load from specific path.
1455    ///
1456    /// # Errors
1457    ///
1458    /// Returns [`Error::NotFound`] if `path` does not exist, [`Error::Io`] for
1459    /// other read errors, and [`Error`] variants for JSON-parse or validation
1460    /// failures.
1461    pub fn load_from(path: &Path) -> Result<Self, Error> {
1462        let content = std::fs::read_to_string(path).map_err(|e| {
1463            if e.kind() == std::io::ErrorKind::NotFound {
1464                Error::NotFound(path.into())
1465            } else {
1466                Error::Io(path.into(), e)
1467            }
1468        })?;
1469
1470        Self::load_from_str(&content, path)
1471    }
1472
1473    /// Load from string content
1474    fn load_from_str(content: &str, path: &Path) -> Result<Self, Error> {
1475        let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
1476
1477        // Try multi-database format first
1478        if let Ok(multi) = toml::from_str::<MultiDbConfig>(content)
1479            && !multi.databases.is_empty()
1480        {
1481            let mut config = Self {
1482                databases: multi.databases,
1483                is_single: false,
1484            };
1485            for db in config.databases.values_mut() {
1486                db.normalize_paths(base_dir);
1487            }
1488            config.validate()?;
1489            return Ok(config);
1490        }
1491
1492        // Fall back to single-database format
1493        let db_config: DatabaseConfig =
1494            toml::from_str(content).map_err(|e| Error::Parse(path.into(), e))?;
1495
1496        let mut databases = HashMap::new();
1497        databases.insert(DEFAULT_DB.to_string(), db_config);
1498
1499        let mut config = Self {
1500            databases,
1501            is_single: true,
1502        };
1503        for db in config.databases.values_mut() {
1504            db.normalize_paths(base_dir);
1505        }
1506        config.validate()?;
1507        Ok(config)
1508    }
1509
1510    fn validate(&self) -> Result<(), Error> {
1511        for (name, db) in &self.databases {
1512            db.validate(name)?;
1513        }
1514        Ok(())
1515    }
1516
1517    /// Check if this is a single-database config
1518    #[must_use]
1519    pub const fn is_single_database(&self) -> bool {
1520        self.is_single
1521    }
1522
1523    /// Get all database names
1524    pub fn database_names(&self) -> impl Iterator<Item = &str> {
1525        self.databases.keys().map(String::as_str)
1526    }
1527
1528    /// Get a specific database config by name.
1529    ///
1530    /// If name is `None`, returns the default/only database.
1531    /// For single-db configs, any name or `None` returns the single database.
1532    ///
1533    /// # Errors
1534    ///
1535    /// Returns [`Error::NoDatabases`] if the config has no databases,
1536    /// [`Error::DatabaseNotFound`] if `name` does not match any configured
1537    /// database, or [`Error::DatabaseRequired`] if multiple databases exist
1538    /// and no name was supplied.
1539    pub fn database(&self, name: Option<&str>) -> Result<&DatabaseConfig, Error> {
1540        name.map_or_else(
1541            || {
1542                // Get default
1543                if self.is_single {
1544                    self.databases.get(DEFAULT_DB).ok_or(Error::NoDatabases)
1545                } else if self.databases.len() == 1 {
1546                    self.databases.values().next().ok_or(Error::NoDatabases)
1547                } else {
1548                    Err(Error::DatabaseRequired(
1549                        self.databases.keys().cloned().collect(),
1550                    ))
1551                }
1552            },
1553            |name| {
1554                if self.is_single {
1555                    // For single-db config, accept any name
1556                    self.databases.get(DEFAULT_DB).ok_or(Error::NoDatabases)
1557                } else {
1558                    self.databases
1559                        .get(name)
1560                        .ok_or_else(|| Error::DatabaseNotFound(name.to_string()))
1561                }
1562            },
1563        )
1564    }
1565
1566    /// Get the default database (for single-db mode or when only one db exists).
1567    ///
1568    /// # Errors
1569    ///
1570    /// Returns the same errors as [`Self::database`] invoked with `None`.
1571    pub fn default_database(&self) -> Result<&DatabaseConfig, Error> {
1572        self.database(None)
1573    }
1574
1575    // ========================================================================
1576    // Backwards compatibility - delegate to default database
1577    // ========================================================================
1578
1579    /// Get dialect (for single-db mode backwards compat)
1580    #[must_use]
1581    pub fn dialect(&self) -> Dialect {
1582        self.default_database()
1583            .map(|d| d.dialect)
1584            .unwrap_or_default()
1585    }
1586
1587    /// Get credentials (for single-db mode backwards compat).
1588    ///
1589    /// # Errors
1590    ///
1591    /// Returns [`Error`] if resolving the default database or its credentials
1592    /// fails (see [`Self::default_database`] and [`DatabaseConfig::credentials`]).
1593    pub fn credentials(&self) -> Result<Option<Credentials>, Error> {
1594        self.default_database()?.credentials()
1595    }
1596
1597    /// Get migrations directory (for single-db mode backwards compat)
1598    #[must_use]
1599    pub fn migrations_dir(&self) -> &Path {
1600        self.default_database()
1601            .map_or_else(|_| Path::new("./drizzle"), |d| d.migrations_dir())
1602    }
1603
1604    /// Get journal path (for single-db mode backwards compat)
1605    #[must_use]
1606    pub fn journal_path(&self) -> PathBuf {
1607        self.default_database().map_or_else(
1608            |_| PathBuf::from("./drizzle/meta/_journal.json"),
1609            DatabaseConfig::journal_path,
1610        )
1611    }
1612
1613    /// Get schema display (for single-db mode backwards compat)
1614    #[must_use]
1615    pub fn schema_display(&self) -> String {
1616        self.default_database()
1617            .map_or_else(|_| "src/schema.rs".into(), DatabaseConfig::schema_display)
1618    }
1619
1620    /// Get schema files (for single-db mode backwards compat).
1621    ///
1622    /// # Errors
1623    ///
1624    /// Returns [`Error`] if resolving the default database fails or if
1625    /// resolving its schema files fails (see [`DatabaseConfig::schema_files`]).
1626    pub fn schema_files(&self) -> Result<Vec<PathBuf>, Error> {
1627        self.default_database()?.schema_files()
1628    }
1629
1630    /// Base dialect for SQL generation (for single-db mode backwards compat)
1631    #[must_use]
1632    pub fn base_dialect(&self) -> drizzle_types::Dialect {
1633        self.dialect().to_base()
1634    }
1635}
1636
1637/// Resolves schema paths and glob patterns to the files they name.
1638///
1639/// Every listed path must exist, and every glob pattern must be valid, read
1640/// cleanly and match at least one file. Reading fewer schema files than the
1641/// config lists would make `push` and `generate` treat the missing tables as
1642/// removed.
1643///
1644/// # Errors
1645///
1646/// Returns [`Error::SchemaPathNotFound`] for a path that does not exist,
1647/// [`Error::Glob`] for an invalid pattern, [`Error::GlobRead`] when expanding
1648/// a pattern fails, and [`Error::SchemaPatternMatchedNothing`] for a pattern
1649/// that matches no file.
1650pub fn resolve_schema_patterns<'a>(
1651    patterns: impl IntoIterator<Item = &'a str>,
1652) -> Result<Vec<PathBuf>, Error> {
1653    let mut files = Vec::new();
1654
1655    for pattern in patterns {
1656        let pat = pattern.trim();
1657        let is_glob = pat.contains('*') || pat.contains('?') || pat.contains('[');
1658
1659        if !is_glob {
1660            // A direct path; accept either separator on Windows.
1661            let path = PathBuf::from(pat);
1662            let normalized = PathBuf::from(pat.replace('\\', "/"));
1663            if path.is_file() {
1664                files.push(path);
1665            } else if normalized.is_file() {
1666                files.push(normalized);
1667            } else {
1668                return Err(Error::SchemaPathNotFound(pat.into()));
1669            }
1670            continue;
1671        }
1672
1673        // Normalize separators so `\` is not read as an escape.
1674        let pat_norm = pat.replace('\\', "/");
1675        let paths = glob::glob(&pat_norm).map_err(|e| Error::Glob(pat.into(), e))?;
1676        let mut matched = false;
1677        for entry in paths {
1678            let path = entry.map_err(|e| Error::GlobRead(pat.into(), e))?;
1679            // Glob can return directories.
1680            if path.is_file() {
1681                files.push(path);
1682                matched = true;
1683            }
1684        }
1685        if !matched {
1686            return Err(Error::SchemaPatternMatchedNothing(pat.into()));
1687        }
1688    }
1689
1690    files.sort();
1691    files.dedup();
1692    Ok(files)
1693}
1694
1695// ============================================================================
1696// Errors
1697// ============================================================================
1698
1699#[derive(Debug, thiserror::Error)]
1700pub enum Error {
1701    #[error("config not found: {}", .0.display())]
1702    NotFound(PathBuf),
1703
1704    #[error("failed to read {}: {}", .0.display(), .1)]
1705    Io(PathBuf, #[source] std::io::Error),
1706
1707    #[error("failed to parse {}: {}", .0.display(), .1)]
1708    Parse(PathBuf, #[source] toml::de::Error),
1709
1710    #[error("driver '{driver}' invalid for {dialect} dialect")]
1711    InvalidDriver { driver: Driver, dialect: Dialect },
1712
1713    #[error("invalid credentials: {0}")]
1714    InvalidCredentials(String),
1715
1716    #[error("invalid config: {0}")]
1717    InvalidConfig(String),
1718
1719    #[error("invalid glob '{0}': {1}")]
1720    Glob(String, #[source] glob::PatternError),
1721
1722    #[error("failed to read a path matched by glob '{0}': {1}")]
1723    GlobRead(String, #[source] glob::GlobError),
1724
1725    #[error("schema path '{0}' does not exist")]
1726    SchemaPathNotFound(String),
1727
1728    #[error("schema pattern '{0}' matches no file")]
1729    SchemaPatternMatchedNothing(String),
1730
1731    #[error("no schema files found: {0}")]
1732    NoSchemaFiles(String),
1733
1734    #[error("environment variable '{0}' not found")]
1735    EnvNotFound(String),
1736
1737    #[error("environment variable '{0}' invalid: {1}")]
1738    EnvInvalid(String, String),
1739
1740    #[error("no databases configured")]
1741    NoDatabases,
1742
1743    #[error("database '{0}' not found")]
1744    DatabaseNotFound(String),
1745
1746    #[error("multiple databases configured, use --db to specify: {}", .0.join(", "))]
1747    DatabaseRequired(Vec<String>),
1748}
1749
1750impl From<ConfigValueError> for Error {
1751    fn from(err: ConfigValueError) -> Self {
1752        match err {
1753            ConfigValueError::NotPresent(var) => Self::EnvNotFound(var),
1754            ConfigValueError::NotUnicode(var) => {
1755                Self::EnvInvalid(var, "contains invalid unicode".into())
1756            }
1757        }
1758    }
1759}
1760
1761// ============================================================================
1762// Tests
1763// ============================================================================
1764
1765#[cfg(test)]
1766mod tests {
1767    use super::*;
1768    use std::fs;
1769    use tempfile::TempDir;
1770
1771    #[test]
1772    fn sqlite() {
1773        let cfg = Config::load_from_str(
1774            r#"
1775            dialect = "sqlite"
1776            [dbCredentials]
1777            url = "./dev.db"
1778        "#,
1779            Path::new("test.toml"),
1780        )
1781        .unwrap();
1782        assert!(cfg.is_single_database());
1783        assert!(matches!(
1784            cfg.credentials().unwrap(),
1785            Some(Credentials::Sqlite { .. })
1786        ));
1787    }
1788
1789    #[test]
1790    fn postgres_url() {
1791        let cfg = Config::load_from_str(
1792            r#"
1793            dialect = "postgresql"
1794            [dbCredentials]
1795            url = "postgres://localhost/db"
1796        "#,
1797            Path::new("test.toml"),
1798        )
1799        .unwrap();
1800        assert!(matches!(
1801            cfg.credentials().unwrap(),
1802            Some(Credentials::Postgres(PostgresCreds::Url(_)))
1803        ));
1804    }
1805
1806    #[test]
1807    fn mysql_url_credentials_and_driver_parse() {
1808        let cfg = Config::load_from_str(
1809            r#"
1810            dialect = "mysql"
1811            driver = "mysql-async"
1812            [dbCredentials]
1813            url = "mysql://user:secret@localhost:3306/app"
1814        "#,
1815            Path::new("test.toml"),
1816        )
1817        .unwrap();
1818        let db = cfg.default_database().unwrap();
1819        assert_eq!(db.dialect, Dialect::Mysql);
1820        assert_eq!(db.driver, Some(Driver::MysqlAsync));
1821        assert!(matches!(
1822            db.credentials().unwrap(),
1823            Some(Credentials::MySQL(MySQLCreds::Url(_)))
1824        ));
1825    }
1826
1827    #[test]
1828    fn mysql_host_credentials_use_mysql_defaults() {
1829        let cfg = Config::load_from_str(
1830            r#"
1831            dialect = "mysql"
1832            driver = "mysql-sync"
1833            [dbCredentials]
1834            host = "db.example.com"
1835            user = "app"
1836            password = "secret"
1837            database = "app_db"
1838            ssl = "verify-identity"
1839        "#,
1840            Path::new("test.toml"),
1841        )
1842        .unwrap();
1843        match cfg.default_database().unwrap().credentials().unwrap() {
1844            Some(Credentials::MySQL(MySQLCreds::Host {
1845                port,
1846                ssl,
1847                database,
1848                ..
1849            })) => {
1850                assert_eq!(port, 3306);
1851                assert_eq!(ssl, MySQLSslMode::VerifyIdentity);
1852                assert_eq!(database.as_ref(), "app_db");
1853            }
1854            other => panic!("unexpected credentials: {other:?}"),
1855        }
1856    }
1857
1858    #[test]
1859    fn mysql_host_credentials_reject_unrepresentable_preferred_tls() {
1860        let error = Config::load_from_str(
1861            r#"
1862            dialect = "mysql"
1863            [dbCredentials]
1864            host = "db.example.com"
1865            database = "app_db"
1866            ssl = "preferred"
1867        "#,
1868            Path::new("test.toml"),
1869        )
1870        .expect_err("host credentials cannot express opportunistic TLS");
1871
1872        assert!(
1873            error.to_string().contains("invalid MySQL SSL mode"),
1874            "{error}"
1875        );
1876    }
1877
1878    #[test]
1879    fn mysql_rejects_cross_dialect_driver_and_url() {
1880        let driver_error = Config::load_from_str(
1881            r#"
1882            dialect = "mysql"
1883            driver = "postgres-sync"
1884            [dbCredentials]
1885            url = "mysql://localhost/app"
1886        "#,
1887            Path::new("test.toml"),
1888        )
1889        .expect_err("postgres driver must not target mysql");
1890        assert!(driver_error.to_string().contains("invalid for mysql"));
1891
1892        let url_error = Config::load_from_str(
1893            r#"
1894            dialect = "mysql"
1895            [dbCredentials]
1896            url = "postgres://localhost/app"
1897        "#,
1898            Path::new("test.toml"),
1899        )
1900        .expect_err("postgres URL must not target mysql");
1901        assert!(url_error.to_string().contains("must start with mysql://"));
1902    }
1903
1904    #[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
1905    #[test]
1906    fn postgres_host_credentials_are_structured_without_url_encoding() {
1907        let credentials = PostgresCreds::Host {
1908            host: "db.example.com".into(),
1909            port: 5432,
1910            user: Some("user@tenant".into()),
1911            password: Some("p:a/ss% word".into()),
1912            database: "app/db name".into(),
1913            ssl: PostgresSslMode::VerifyFull,
1914        };
1915
1916        let connection = credentials.connection_config().expect("build config");
1917        assert_eq!(connection.config.get_user(), Some("user@tenant"));
1918        assert_eq!(connection.config.get_password(), Some(&b"p:a/ss% word"[..]));
1919        assert_eq!(connection.config.get_dbname(), Some("app/db name"));
1920        assert_eq!(
1921            connection.config.get_ssl_mode(),
1922            tokio_postgres::config::SslMode::Require
1923        );
1924        assert_eq!(connection.ssl, PostgresSslMode::VerifyFull);
1925    }
1926
1927    #[test]
1928    fn multi_database() {
1929        let cfg = Config::load_from_str(
1930            r#"
1931            [databases.dev]
1932            dialect = "sqlite"
1933            out = "./drizzle/sqlite"
1934            [databases.dev.dbCredentials]
1935            url = "./dev.db"
1936
1937            [databases.prod]
1938            dialect = "postgresql"
1939            out = "./drizzle/postgres"
1940            [databases.prod.dbCredentials]
1941            url = "postgres://localhost/db"
1942        "#,
1943            Path::new("test.toml"),
1944        )
1945        .unwrap();
1946
1947        assert!(!cfg.is_single_database());
1948        let names: Vec<_> = cfg.database_names().collect();
1949        assert!(names.contains(&"dev"));
1950        assert!(names.contains(&"prod"));
1951
1952        let dev = cfg.database(Some("dev")).unwrap();
1953        assert_eq!(dev.dialect, Dialect::Sqlite);
1954
1955        let prod = cfg.database(Some("prod")).unwrap();
1956        assert_eq!(prod.dialect, Dialect::Postgresql);
1957    }
1958
1959    #[test]
1960    fn multi_database_requires_selection() {
1961        let cfg = Config::load_from_str(
1962            r#"
1963            [databases.a]
1964            dialect = "sqlite"
1965            [databases.b]
1966            dialect = "postgresql"
1967        "#,
1968            Path::new("test.toml"),
1969        )
1970        .unwrap();
1971
1972        // Should error when no db specified with multiple dbs
1973        assert!(cfg.database(None).is_err());
1974    }
1975
1976    #[test]
1977    fn env_var_syntax() {
1978        let cfg = Config::load_from_str(
1979            r#"
1980            dialect = "postgresql"
1981            [dbCredentials]
1982            url = { env = "DATABASE_URL" }
1983        "#,
1984            Path::new("test.toml"),
1985        )
1986        .unwrap();
1987        assert!(cfg.is_single_database());
1988    }
1989
1990    #[test]
1991    fn casing_options() {
1992        let cfg = Config::load_from_str(
1993            r#"
1994            dialect = "postgresql"
1995            casing = "snake_case"
1996            [dbCredentials]
1997            url = "postgres://localhost/db"
1998        "#,
1999            Path::new("test.toml"),
2000        )
2001        .unwrap();
2002        let db = cfg.default_database().unwrap();
2003        assert_eq!(db.effective_casing(), Casing::SnakeCase);
2004
2005        // Test default (camelCase)
2006        let cfg2 = Config::load_from_str(
2007            r#"
2008            dialect = "postgresql"
2009            [dbCredentials]
2010            url = "postgres://localhost/db"
2011        "#,
2012            Path::new("test.toml"),
2013        )
2014        .unwrap();
2015        let db2 = cfg2.default_database().unwrap();
2016        assert_eq!(db2.effective_casing(), Casing::CamelCase);
2017    }
2018
2019    #[test]
2020    fn introspect_casing() {
2021        let cfg = Config::load_from_str(
2022            r#"
2023            dialect = "postgresql"
2024            [introspect]
2025            casing = "preserve"
2026            [dbCredentials]
2027            url = "postgres://localhost/db"
2028        "#,
2029            Path::new("test.toml"),
2030        )
2031        .unwrap();
2032        let db = cfg.default_database().unwrap();
2033        assert_eq!(db.effective_introspect_casing(), IntrospectCasing::Preserve);
2034    }
2035
2036    #[test]
2037    fn entities_roles_filter() {
2038        // Test boolean roles filter
2039        let cfg = Config::load_from_str(
2040            r#"
2041            dialect = "postgresql"
2042            [entities]
2043            roles = true
2044            [dbCredentials]
2045            url = "postgres://localhost/db"
2046        "#,
2047            Path::new("test.toml"),
2048        )
2049        .unwrap();
2050        let db = cfg.default_database().unwrap();
2051        assert!(db.roles_enabled());
2052        assert!(db.should_include_role("my_role"));
2053
2054        // Test roles filter with provider
2055        let cfg2 = Config::load_from_str(
2056            r#"
2057            dialect = "postgresql"
2058            [entities.roles]
2059            provider = "supabase"
2060            [dbCredentials]
2061            url = "postgres://localhost/db"
2062        "#,
2063            Path::new("test.toml"),
2064        )
2065        .unwrap();
2066        let db2 = cfg2.default_database().unwrap();
2067        assert!(db2.roles_enabled());
2068        assert!(!db2.should_include_role("anon")); // Supabase built-in
2069        assert!(db2.should_include_role("my_custom_role"));
2070    }
2071
2072    #[test]
2073    fn extensions_filter() {
2074        let cfg = Config::load_from_str(
2075            r#"
2076            dialect = "postgresql"
2077            extensionsFilters = ["postgis"]
2078            [dbCredentials]
2079            url = "postgres://localhost/db"
2080        "#,
2081            Path::new("test.toml"),
2082        )
2083        .unwrap();
2084        let db = cfg.default_database().unwrap();
2085        assert!(db.has_extension(Extension::Postgis));
2086    }
2087
2088    #[test]
2089    fn rejects_postgres_only_filters_for_sqlite() {
2090        let err = Config::load_from_str(
2091            r#"
2092            dialect = "sqlite"
2093            schemaFilter = ["public"]
2094            [dbCredentials]
2095            url = "./dev.db"
2096        "#,
2097            Path::new("test.toml"),
2098        )
2099        .expect_err("sqlite should reject schemaFilter");
2100        assert_eq!(
2101            err.to_string(),
2102            "invalid config: schemaFilter is only supported for dialect = \"postgresql\""
2103        );
2104
2105        let err = Config::load_from_str(
2106            r#"
2107            dialect = "sqlite"
2108            extensionsFilters = ["postgis"]
2109            [dbCredentials]
2110            url = "./dev.db"
2111        "#,
2112            Path::new("test.toml"),
2113        )
2114        .expect_err("sqlite should reject extensionsFilters");
2115        assert_eq!(
2116            err.to_string(),
2117            "invalid config: extensionsFilters is only supported for dialect = \"postgresql\""
2118        );
2119    }
2120
2121    #[test]
2122    fn rejects_entities_filter_for_turso() {
2123        let err = Config::load_from_str(
2124            r#"
2125            dialect = "turso"
2126            [entities]
2127            roles = true
2128            [dbCredentials]
2129            url = "libsql://example.turso.io"
2130        "#,
2131            Path::new("test.toml"),
2132        )
2133        .expect_err("turso should reject entities filter");
2134        assert_eq!(
2135            err.to_string(),
2136            "invalid config: entities filter is only supported for dialect = \"postgresql\""
2137        );
2138    }
2139
2140    #[test]
2141    fn migrations_config() {
2142        let cfg = Config::load_from_str(
2143            r#"
2144            dialect = "postgresql"
2145            [migrations]
2146            table = "custom_migrations"
2147            schema = "custom_schema"
2148            [dbCredentials]
2149            url = "postgres://localhost/db"
2150        "#,
2151            Path::new("test.toml"),
2152        )
2153        .unwrap();
2154        let db = cfg.default_database().unwrap();
2155        assert_eq!(db.migrations_table(), "custom_migrations");
2156        assert_eq!(db.migrations_schema(), "custom_schema");
2157
2158        // Test defaults
2159        let cfg2 = Config::load_from_str(
2160            r#"
2161            dialect = "postgresql"
2162            [dbCredentials]
2163            url = "postgres://localhost/db"
2164        "#,
2165            Path::new("test.toml"),
2166        )
2167        .unwrap();
2168        let db2 = cfg2.default_database().unwrap();
2169        assert_eq!(db2.migrations_table(), "__drizzle_migrations");
2170        assert_eq!(db2.migrations_schema(), "drizzle");
2171    }
2172
2173    #[test]
2174    fn resolves_paths_relative_to_config_dir() {
2175        let tmp = TempDir::new().unwrap();
2176        let cfg_dir = tmp.path().join("cfg");
2177        fs::create_dir_all(&cfg_dir).unwrap();
2178
2179        // Create schema file next to config file.
2180        let schema_path = cfg_dir.join("schema.rs");
2181        fs::write(&schema_path, "#[allow(dead_code)]\npub struct X;").unwrap();
2182
2183        let cfg_path = cfg_dir.join("drizzle.config.toml");
2184        let cfg = Config::load_from_str(
2185            r#"
2186            dialect = "sqlite"
2187            schema = "schema.rs"
2188            out = "./drizzle"
2189            [dbCredentials]
2190            url = "./dev.db"
2191        "#,
2192            &cfg_path,
2193        )
2194        .unwrap();
2195
2196        let db = cfg.default_database().unwrap();
2197        assert_eq!(db.migrations_dir(), cfg_dir.join("./drizzle").as_path());
2198
2199        let files = db.schema_files().unwrap();
2200        assert_eq!(files.len(), 1);
2201        assert_eq!(files[0], schema_path);
2202    }
2203
2204    #[test]
2205    fn rejects_host_credentials_for_sqlite() {
2206        let err = Config::load_from_str(
2207            r#"
2208            dialect = "sqlite"
2209            [dbCredentials]
2210            host = "localhost"
2211            database = "db"
2212        "#,
2213            Path::new("test.toml"),
2214        )
2215        .unwrap_err();
2216
2217        assert_eq!(
2218            err.to_string(),
2219            "invalid credentials: host-based dbCredentials are only supported for dialect = \"postgresql\" or \"mysql\""
2220        );
2221    }
2222
2223    // ========================================================================
2224    // Cloudflare: D1 HTTP and Durable Objects SQLite
2225    // ========================================================================
2226
2227    #[test]
2228    fn d1_http_credentials_parse() {
2229        let cfg = Config::load_from_str(
2230            r#"
2231            dialect = "sqlite"
2232            driver = "d1-http"
2233            [dbCredentials]
2234            accountId = "acc_abc"
2235            databaseId = "db_xyz"
2236            token = "tok_123"
2237        "#,
2238            Path::new("test.toml"),
2239        )
2240        .unwrap();
2241
2242        let db = cfg.default_database().unwrap();
2243        assert_eq!(db.driver, Some(Driver::D1Http));
2244        match db.credentials().unwrap() {
2245            Some(Credentials::D1 {
2246                account_id,
2247                database_id,
2248                token,
2249            }) => {
2250                assert_eq!(&*account_id, "acc_abc");
2251                assert_eq!(&*database_id, "db_xyz");
2252                assert_eq!(&*token, "tok_123");
2253            }
2254            other => panic!("expected Credentials::D1, got {other:?}"),
2255        }
2256    }
2257
2258    #[test]
2259    fn d1_http_credentials_resolve_from_env() {
2260        // Unique env var names per-test so parallel tests don't collide.
2261        unsafe {
2262            std::env::set_var("TEST_D1_ACCT", "env_acct");
2263            std::env::set_var("TEST_D1_DB", "env_db");
2264            std::env::set_var("TEST_D1_TOKEN", "env_token");
2265        }
2266        let cfg = Config::load_from_str(
2267            r#"
2268            dialect = "sqlite"
2269            driver = "d1-http"
2270            [dbCredentials]
2271            accountId = { env = "TEST_D1_ACCT" }
2272            databaseId = { env = "TEST_D1_DB" }
2273            token = { env = "TEST_D1_TOKEN" }
2274        "#,
2275            Path::new("test.toml"),
2276        )
2277        .unwrap();
2278
2279        match cfg.default_database().unwrap().credentials().unwrap() {
2280            Some(Credentials::D1 {
2281                account_id,
2282                database_id,
2283                token,
2284            }) => {
2285                assert_eq!(&*account_id, "env_acct");
2286                assert_eq!(&*database_id, "env_db");
2287                assert_eq!(&*token, "env_token");
2288            }
2289            other => panic!("expected Credentials::D1, got {other:?}"),
2290        }
2291    }
2292
2293    #[test]
2294    fn d1_credentials_require_sqlite_dialect() {
2295        let err = Config::load_from_str(
2296            r#"
2297            dialect = "postgresql"
2298            [dbCredentials]
2299            accountId = "acc"
2300            databaseId = "db"
2301            token = "tok"
2302        "#,
2303            Path::new("test.toml"),
2304        )
2305        .unwrap_err();
2306        assert!(
2307            err.to_string().contains("D1 dbCredentials"),
2308            "expected D1-specific error, got: {err}"
2309        );
2310    }
2311
2312    #[test]
2313    fn d1_credentials_require_d1_http_driver() {
2314        // Same SQLite dialect, but driver is rusqlite — should be rejected.
2315        let err = Config::load_from_str(
2316            r#"
2317            dialect = "sqlite"
2318            driver = "rusqlite"
2319            [dbCredentials]
2320            accountId = "acc"
2321            databaseId = "db"
2322            token = "tok"
2323        "#,
2324            Path::new("test.toml"),
2325        )
2326        .unwrap_err();
2327        assert!(
2328            err.to_string().contains("driver = \"d1-http\""),
2329            "expected d1-http driver error, got: {err}"
2330        );
2331    }
2332
2333    #[test]
2334    fn d1_http_driver_requires_d1_credentials() {
2335        // Driver is d1-http but creds are URL-shaped — should be rejected.
2336        let err = Config::load_from_str(
2337            r#"
2338            dialect = "sqlite"
2339            driver = "d1-http"
2340            [dbCredentials]
2341            url = "./dev.db"
2342        "#,
2343            Path::new("test.toml"),
2344        )
2345        .unwrap_err();
2346        assert!(
2347            err.to_string().contains("accountId, databaseId, and token"),
2348            "expected d1-http creds-shape error, got: {err}"
2349        );
2350    }
2351
2352    #[test]
2353    fn durable_sqlite_no_credentials_ok() {
2354        // Durable Objects don't need credentials — migrations are applied inside
2355        // the Worker runtime. Loading without dbCredentials should succeed.
2356        let cfg = Config::load_from_str(
2357            r#"
2358            dialect = "sqlite"
2359            driver = "durable-sqlite"
2360        "#,
2361            Path::new("test.toml"),
2362        )
2363        .unwrap();
2364
2365        let db = cfg.default_database().unwrap();
2366        assert_eq!(db.driver, Some(Driver::DurableSqlite));
2367        assert!(db.credentials().unwrap().is_none());
2368        // Bundle should auto-enable so migrations.js gets emitted for the Worker.
2369        assert!(
2370            db.bundle_enabled(),
2371            "durable-sqlite should auto-enable bundle"
2372        );
2373    }
2374
2375    #[test]
2376    fn durable_sqlite_explicit_bundle_false_respected() {
2377        // Explicit opt-out must override the durable-sqlite auto-enable.
2378        let cfg = Config::load_from_str(
2379            r#"
2380            dialect = "sqlite"
2381            driver = "durable-sqlite"
2382            [migrations]
2383            bundle = false
2384        "#,
2385            Path::new("test.toml"),
2386        )
2387        .unwrap();
2388        assert!(!cfg.default_database().unwrap().bundle_enabled());
2389    }
2390
2391    #[test]
2392    fn durable_sqlite_rejects_non_sqlite_dialect() {
2393        let err = Config::load_from_str(
2394            r#"
2395            dialect = "postgresql"
2396            driver = "durable-sqlite"
2397            [dbCredentials]
2398            url = "postgres://localhost/db"
2399        "#,
2400            Path::new("test.toml"),
2401        )
2402        .unwrap_err();
2403        assert!(
2404            err.to_string().contains("invalid for postgresql"),
2405            "expected dialect/driver mismatch error, got: {err}"
2406        );
2407    }
2408
2409    #[test]
2410    fn driver_valid_for_sqlite_includes_cloudflare() {
2411        let drivers = Driver::valid_for(Dialect::Sqlite);
2412        assert!(drivers.contains(&Driver::Rusqlite));
2413        assert!(drivers.contains(&Driver::D1Http));
2414        assert!(drivers.contains(&Driver::DurableSqlite));
2415        // D1/DO must not leak into other dialects.
2416        for drv in [Driver::D1Http, Driver::DurableSqlite] {
2417            assert!(!drv.is_valid_for(Dialect::Postgresql));
2418            assert!(!drv.is_valid_for(Dialect::Turso));
2419        }
2420    }
2421
2422    #[test]
2423    fn driver_is_codegen_only_flag() {
2424        assert!(Driver::DurableSqlite.is_codegen_only());
2425        assert!(!Driver::D1Http.is_codegen_only());
2426        assert!(!Driver::Rusqlite.is_codegen_only());
2427        assert!(!Driver::AwsDataApi.is_codegen_only());
2428    }
2429
2430    // ========================================================================
2431    // AWS RDS Data API (Aurora Serverless PostgreSQL)
2432    // ========================================================================
2433
2434    #[test]
2435    fn aws_data_api_credentials_parse() {
2436        let cfg = Config::load_from_str(
2437            r#"
2438            dialect = "postgresql"
2439            driver = "aws-data-api"
2440            [dbCredentials]
2441            database = "mydb"
2442            secretArn = "arn:aws:secretsmanager:us-east-1:123:secret:db-xyz"
2443            resourceArn = "arn:aws:rds:us-east-1:123:cluster:my-aurora"
2444        "#,
2445            Path::new("test.toml"),
2446        )
2447        .unwrap();
2448
2449        let db = cfg.default_database().unwrap();
2450        assert_eq!(db.driver, Some(Driver::AwsDataApi));
2451        match db.credentials().unwrap() {
2452            Some(Credentials::AwsDataApi {
2453                database,
2454                secret_arn,
2455                resource_arn,
2456            }) => {
2457                assert_eq!(&*database, "mydb");
2458                assert!(secret_arn.starts_with("arn:aws:secretsmanager"));
2459                assert!(resource_arn.starts_with("arn:aws:rds"));
2460            }
2461            other => panic!("expected Credentials::AwsDataApi, got {other:?}"),
2462        }
2463    }
2464
2465    #[test]
2466    fn aws_data_api_credentials_resolve_from_env() {
2467        unsafe {
2468            std::env::set_var("TEST_AWS_DB", "envdb");
2469            std::env::set_var("TEST_AWS_SECRET", "arn:env:secret");
2470            std::env::set_var("TEST_AWS_RESOURCE", "arn:env:resource");
2471        }
2472        let cfg = Config::load_from_str(
2473            r#"
2474            dialect = "postgresql"
2475            driver = "aws-data-api"
2476            [dbCredentials]
2477            database = { env = "TEST_AWS_DB" }
2478            secretArn = { env = "TEST_AWS_SECRET" }
2479            resourceArn = { env = "TEST_AWS_RESOURCE" }
2480        "#,
2481            Path::new("test.toml"),
2482        )
2483        .unwrap();
2484
2485        match cfg.default_database().unwrap().credentials().unwrap() {
2486            Some(Credentials::AwsDataApi {
2487                database,
2488                secret_arn,
2489                resource_arn,
2490            }) => {
2491                assert_eq!(&*database, "envdb");
2492                assert_eq!(&*secret_arn, "arn:env:secret");
2493                assert_eq!(&*resource_arn, "arn:env:resource");
2494            }
2495            other => panic!("expected Credentials::AwsDataApi, got {other:?}"),
2496        }
2497    }
2498
2499    #[test]
2500    fn aws_data_api_requires_postgres_dialect() {
2501        let err = Config::load_from_str(
2502            r#"
2503            dialect = "sqlite"
2504            [dbCredentials]
2505            database = "mydb"
2506            secretArn = "arn:aws:secretsmanager:..."
2507            resourceArn = "arn:aws:rds:..."
2508        "#,
2509            Path::new("test.toml"),
2510        )
2511        .unwrap_err();
2512        assert!(
2513            err.to_string().contains("AWS Data API dbCredentials"),
2514            "expected AWS-specific error, got: {err}"
2515        );
2516    }
2517
2518    #[test]
2519    fn aws_data_api_requires_aws_data_api_driver() {
2520        // Same postgresql dialect, but driver is tokio-postgres — should be rejected.
2521        let err = Config::load_from_str(
2522            r#"
2523            dialect = "postgresql"
2524            driver = "tokio-postgres"
2525            [dbCredentials]
2526            database = "mydb"
2527            secretArn = "arn:aws:secretsmanager:..."
2528            resourceArn = "arn:aws:rds:..."
2529        "#,
2530            Path::new("test.toml"),
2531        )
2532        .unwrap_err();
2533        assert!(
2534            err.to_string().contains("driver = \"aws-data-api\""),
2535            "expected aws-data-api driver error, got: {err}"
2536        );
2537    }
2538
2539    #[test]
2540    fn aws_data_api_driver_requires_aws_credentials() {
2541        // driver = aws-data-api but creds are URL-shaped — should be rejected.
2542        let err = Config::load_from_str(
2543            r#"
2544            dialect = "postgresql"
2545            driver = "aws-data-api"
2546            [dbCredentials]
2547            url = "postgres://localhost/db"
2548        "#,
2549            Path::new("test.toml"),
2550        )
2551        .unwrap_err();
2552        assert!(
2553            err.to_string()
2554                .contains("database, secretArn, and resourceArn"),
2555            "expected aws-data-api creds-shape error, got: {err}"
2556        );
2557    }
2558
2559    #[test]
2560    fn aws_data_api_rejected_for_non_postgres_dialect() {
2561        let err = Config::load_from_str(
2562            r#"
2563            dialect = "sqlite"
2564            driver = "aws-data-api"
2565        "#,
2566            Path::new("test.toml"),
2567        )
2568        .unwrap_err();
2569        assert!(
2570            err.to_string().contains("invalid for sqlite"),
2571            "expected dialect/driver mismatch error, got: {err}"
2572        );
2573    }
2574
2575    #[test]
2576    fn driver_valid_for_postgres_includes_aws_data_api() {
2577        let drivers = Driver::valid_for(Dialect::Postgresql);
2578        assert!(drivers.contains(&Driver::PostgresSync));
2579        assert!(drivers.contains(&Driver::TokioPostgres));
2580        assert!(drivers.contains(&Driver::AwsDataApi));
2581        // Must not leak into other dialects.
2582        assert!(!Driver::AwsDataApi.is_valid_for(Dialect::Sqlite));
2583        assert!(!Driver::AwsDataApi.is_valid_for(Dialect::Turso));
2584    }
2585
2586    #[test]
2587    fn credentials_debug_redacts_secrets() {
2588        let postgres = Credentials::Postgres(PostgresCreds::Host {
2589            host: "localhost".into(),
2590            port: 5432,
2591            user: Some("alice".into()),
2592            password: Some("super-secret-password".into()),
2593            database: "app".into(),
2594            ssl: PostgresSslMode::Require,
2595        });
2596        let turso = Credentials::Turso {
2597            url: "libsql://example.turso.io".into(),
2598            auth_token: Some("super-secret-token".into()),
2599        };
2600        let d1 = Credentials::D1 {
2601            account_id: "account".into(),
2602            database_id: "database".into(),
2603            token: "super-secret-d1-token".into(),
2604        };
2605        let postgres_url = Credentials::Postgres(PostgresCreds::Url(
2606            "postgres://alice:super-secret-url-password@localhost/app".into(),
2607        ));
2608
2609        let rendered = format!("{postgres:?} {turso:?} {d1:?} {postgres_url:?}");
2610        assert!(rendered.contains("[REDACTED]"));
2611        assert!(!rendered.contains("super-secret"));
2612    }
2613
2614    #[cfg(windows)]
2615    #[test]
2616    fn schema_files_accept_backslash_paths() {
2617        let tmp = TempDir::new().unwrap();
2618        let cfg_dir = tmp.path().join("cfg");
2619        fs::create_dir_all(&cfg_dir).unwrap();
2620
2621        let schema_path = cfg_dir.join("src").join("schema.rs");
2622        fs::create_dir_all(schema_path.parent().unwrap()).unwrap();
2623        fs::write(&schema_path, "#[allow(dead_code)]\npub struct X;").unwrap();
2624
2625        // Write schema path with backslashes (common on Windows).
2626        let schema_str = schema_path.to_string_lossy().replace('/', "\\");
2627        // TOML basic strings treat backslash as an escape; double-escape to embed a Windows path.
2628        let schema_toml = schema_str.replace('\\', "\\\\");
2629        let cfg_path = cfg_dir.join("drizzle.config.toml");
2630        let cfg = Config::load_from_str(
2631            &format!(
2632                r#"
2633                dialect = "sqlite"
2634                schema = "{}"
2635            "#,
2636                schema_toml
2637            ),
2638            &cfg_path,
2639        )
2640        .unwrap();
2641
2642        let db = cfg.default_database().unwrap();
2643        let files = db.schema_files().unwrap();
2644        assert_eq!(files, vec![schema_path]);
2645    }
2646
2647    /// Reading fewer schema files than listed would make push and generate
2648    /// treat the missing tables as dropped, so every listed path must exist
2649    /// and every pattern must match.
2650    #[test]
2651    fn schema_resolution_rejects_any_missing_path_or_empty_pattern() {
2652        let tmp = TempDir::new().unwrap();
2653        let present = tmp.path().join("present.rs");
2654        fs::write(&present, "pub struct Present;").unwrap();
2655        let present = present.to_string_lossy().replace('\\', "/");
2656        let missing = tmp
2657            .path()
2658            .join("missing.rs")
2659            .to_string_lossy()
2660            .replace('\\', "/");
2661        let empty_glob = format!(
2662            "{}/nothing/*.rs",
2663            tmp.path().to_string_lossy().replace('\\', "/")
2664        );
2665
2666        assert_eq!(
2667            resolve_schema_patterns([present.as_str()]).unwrap().len(),
2668            1
2669        );
2670        assert!(matches!(
2671            resolve_schema_patterns([present.as_str(), missing.as_str()]),
2672            Err(Error::SchemaPathNotFound(path)) if path == missing
2673        ));
2674        assert!(matches!(
2675            resolve_schema_patterns([present.as_str(), empty_glob.as_str()]),
2676            Err(Error::SchemaPatternMatchedNothing(pattern)) if pattern == empty_glob
2677        ));
2678        assert!(matches!(
2679            resolve_schema_patterns(["src/[.rs"]),
2680            Err(Error::Glob(..))
2681        ));
2682    }
2683}