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