Skip to main content

drizzle_cli/
config.rs

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