Skip to main content

drizzle_cli/
config.rs

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