Skip to main content

drizzle_cli/
config.rs

1//! Configuration for Drizzle CLI
2//!
3//! Handles loading `drizzle.config.toml` with type-safe credentials.
4//! Supports both single-database (legacy) and multi-database configurations.
5//!
6//! This configuration format is designed to be compatible with drizzle-kit
7//! so TypeScript users can use the same config expectations.
8
9pub use drizzle_types::{Casing, ConfigValue, ConfigValueError};
10use schemars::JsonSchema;
11use serde::Deserialize;
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15pub const CONFIG_FILE: &str = "drizzle.config.toml";
16
17/// Casing mode for introspection (pull command)
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Deserialize, JsonSchema)]
19pub enum IntrospectCasing {
20    /// Convert database names to camelCase
21    #[default]
22    #[serde(rename = "camel")]
23    Camel,
24    /// Preserve original database names
25    #[serde(rename = "preserve")]
26    Preserve,
27}
28
29impl IntrospectCasing {
30    #[must_use]
31    pub const fn as_str(self) -> &'static str {
32        match self {
33            Self::Camel => "camel",
34            Self::Preserve => "preserve",
35        }
36    }
37}
38
39impl std::fmt::Display for IntrospectCasing {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        f.write_str(self.as_str())
42    }
43}
44
45impl std::str::FromStr for IntrospectCasing {
46    type Err = String;
47
48    fn from_str(s: &str) -> Result<Self, Self::Err> {
49        match s {
50            "camel" | "camelCase" => Ok(Self::Camel),
51            "preserve" => Ok(Self::Preserve),
52            _ => Err(format!(
53                "invalid introspect casing '{s}', expected 'camel' or 'preserve'"
54            )),
55        }
56    }
57}
58
59/// Introspection configuration
60#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
61pub struct IntrospectConfig {
62    /// Casing mode for introspected identifiers
63    #[serde(default)]
64    pub casing: IntrospectCasing,
65}
66
67// ============================================================================
68// Entities Filter (matching drizzle-kit)
69// ============================================================================
70
71/// Roles filter configuration
72///
73/// Can be either a boolean (true = include all, false = exclude all)
74/// or a detailed configuration with provider/include/exclude lists.
75#[derive(Debug, Clone, Deserialize, JsonSchema)]
76#[serde(untagged)]
77pub enum RolesFilter {
78    /// Simple boolean: true = include all user roles, false = exclude all
79    Bool(bool),
80    /// Detailed configuration
81    Config {
82        /// Provider preset (e.g., "supabase", "neon") - excludes provider-specific roles
83        #[serde(default)]
84        provider: Option<String>,
85        /// Explicit list of role names to include
86        #[serde(default)]
87        include: Option<Vec<String>>,
88        /// Explicit list of role names to exclude
89        #[serde(default)]
90        exclude: Option<Vec<String>>,
91    },
92}
93
94impl Default for RolesFilter {
95    fn default() -> Self {
96        Self::Bool(false)
97    }
98}
99
100impl RolesFilter {
101    /// Check if roles should be included at all
102    #[must_use]
103    pub const fn is_enabled(&self) -> bool {
104        match self {
105            Self::Bool(b) => *b,
106            Self::Config { .. } => true,
107        }
108    }
109
110    /// Check if a specific role should be included
111    #[must_use]
112    pub fn should_include(&self, role_name: &str) -> bool {
113        match self {
114            Self::Bool(b) => *b,
115            Self::Config {
116                provider,
117                include,
118                exclude,
119            } => {
120                // Check provider exclusions
121                if let Some(p) = provider
122                    && is_provider_role(p, role_name)
123                {
124                    return false;
125                }
126                // Check explicit exclude list
127                if let Some(excl) = exclude
128                    && excl.iter().any(|e| e == role_name)
129                {
130                    return false;
131                }
132                // Check explicit include list (if specified, only include those)
133                if let Some(incl) = include {
134                    return incl.iter().any(|i| i == role_name);
135                }
136                true
137            }
138        }
139    }
140}
141
142/// Check if a role belongs to a provider's built-in roles
143fn is_provider_role(provider: &str, role_name: &str) -> bool {
144    match provider {
145        "supabase" => matches!(
146            role_name,
147            "anon"
148                | "authenticated"
149                | "service_role"
150                | "supabase_admin"
151                | "supabase_auth_admin"
152                | "supabase_storage_admin"
153                | "dashboard_user"
154                | "supabase_replication_admin"
155                | "supabase_read_only_user"
156                | "supabase_realtime_admin"
157                | "supabase_functions_admin"
158                | "postgres"
159                | "pgbouncer"
160                | "pgsodium_keyholder"
161                | "pgsodium_keyiduser"
162                | "pgsodium_keymaker"
163        ),
164        "neon" => matches!(
165            role_name,
166            "neon_superuser" | "cloud_admin" | "authenticated" | "anonymous"
167        ),
168        _ => false,
169    }
170}
171
172/// Entities filter configuration
173///
174/// Controls which database entities are included in push/pull operations.
175#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
176pub struct EntitiesFilter {
177    /// Roles filter (`PostgreSQL` only)
178    #[serde(default)]
179    pub roles: RolesFilter,
180}
181
182// ============================================================================
183// Extensions Filter (PostgreSQL only)
184// ============================================================================
185
186/// Known `PostgreSQL` extensions that can be filtered
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, JsonSchema)]
188#[serde(rename_all = "lowercase")]
189pub enum Extension {
190    /// `PostGIS` spatial extension
191    Postgis,
192}
193
194impl Extension {
195    #[must_use]
196    pub const fn as_str(self) -> &'static str {
197        match self {
198            Self::Postgis => "postgis",
199        }
200    }
201}
202
203impl std::fmt::Display for Extension {
204    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205        f.write_str(self.as_str())
206    }
207}
208
209// ============================================================================
210// Dialect
211// ============================================================================
212
213/// Database dialect
214#[derive(
215    Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, Deserialize, JsonSchema,
216)]
217#[serde(rename_all = "lowercase")]
218pub enum Dialect {
219    #[default]
220    Sqlite,
221    #[serde(alias = "postgres")]
222    Postgresql,
223    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: ConfigValue,
716        #[serde(rename = "databaseId")]
717        database_id: ConfigValue,
718        token: ConfigValue,
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: ConfigValue,
728        #[serde(rename = "secretArn")]
729        secret_arn: ConfigValue,
730        #[serde(rename = "resourceArn")]
731        resource_arn: ConfigValue,
732    },
733    Url {
734        url: ConfigValue,
735        #[serde(default, rename = "authToken")]
736        auth_token: Option<ConfigValue>,
737    },
738    Host {
739        host: ConfigValue,
740        #[serde(default)]
741        port: Option<u16>,
742        #[serde(default)]
743        user: Option<ConfigValue>,
744        #[serde(default)]
745        password: Option<ConfigValue>,
746        database: ConfigValue,
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: ConfigValue::Inline(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: ConfigValue::Inline(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: ConfigValue::Inline(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: ConfigValue::Inline(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 ConfigValue
1049        let resolve_opt = |opt: &Option<ConfigValue>| -> 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<ConfigValueError> for Error {
1581    fn from(err: ConfigValueError) -> Self {
1582        match err {
1583            ConfigValueError::NotPresent(var) => Self::EnvNotFound(var),
1584            ConfigValueError::NotUnicode(var) => {
1585                Self::EnvInvalid(var, "contains invalid unicode".into())
1586            }
1587        }
1588    }
1589}
1590
1591// ============================================================================
1592// Tests
1593// ============================================================================
1594
1595#[cfg(test)]
1596mod tests {
1597    use super::*;
1598    use std::fs;
1599    use tempfile::TempDir;
1600
1601    #[test]
1602    fn sqlite() {
1603        let cfg = Config::load_from_str(
1604            r#"
1605            dialect = "sqlite"
1606            [dbCredentials]
1607            url = "./dev.db"
1608        "#,
1609            Path::new("test.toml"),
1610        )
1611        .unwrap();
1612        assert!(cfg.is_single_database());
1613        assert!(matches!(
1614            cfg.credentials().unwrap(),
1615            Some(Credentials::Sqlite { .. })
1616        ));
1617    }
1618
1619    #[test]
1620    fn postgres_url() {
1621        let cfg = Config::load_from_str(
1622            r#"
1623            dialect = "postgresql"
1624            [dbCredentials]
1625            url = "postgres://localhost/db"
1626        "#,
1627            Path::new("test.toml"),
1628        )
1629        .unwrap();
1630        assert!(matches!(
1631            cfg.credentials().unwrap(),
1632            Some(Credentials::Postgres(PostgresCreds::Url(_)))
1633        ));
1634    }
1635
1636    #[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
1637    #[test]
1638    fn postgres_host_credentials_are_structured_without_url_encoding() {
1639        let credentials = PostgresCreds::Host {
1640            host: "db.example.com".into(),
1641            port: 5432,
1642            user: Some("user@tenant".into()),
1643            password: Some("p:a/ss% word".into()),
1644            database: "app/db name".into(),
1645            ssl: PostgresSslMode::VerifyFull,
1646        };
1647
1648        let connection = credentials.connection_config().expect("build config");
1649        assert_eq!(connection.config.get_user(), Some("user@tenant"));
1650        assert_eq!(connection.config.get_password(), Some(&b"p:a/ss% word"[..]));
1651        assert_eq!(connection.config.get_dbname(), Some("app/db name"));
1652        assert_eq!(
1653            connection.config.get_ssl_mode(),
1654            tokio_postgres::config::SslMode::Require
1655        );
1656        assert_eq!(connection.ssl, PostgresSslMode::VerifyFull);
1657    }
1658
1659    #[test]
1660    fn multi_database() {
1661        let cfg = Config::load_from_str(
1662            r#"
1663            [databases.dev]
1664            dialect = "sqlite"
1665            out = "./drizzle/sqlite"
1666            [databases.dev.dbCredentials]
1667            url = "./dev.db"
1668
1669            [databases.prod]
1670            dialect = "postgresql"
1671            out = "./drizzle/postgres"
1672            [databases.prod.dbCredentials]
1673            url = "postgres://localhost/db"
1674        "#,
1675            Path::new("test.toml"),
1676        )
1677        .unwrap();
1678
1679        assert!(!cfg.is_single_database());
1680        let names: Vec<_> = cfg.database_names().collect();
1681        assert!(names.contains(&"dev"));
1682        assert!(names.contains(&"prod"));
1683
1684        let dev = cfg.database(Some("dev")).unwrap();
1685        assert_eq!(dev.dialect, Dialect::Sqlite);
1686
1687        let prod = cfg.database(Some("prod")).unwrap();
1688        assert_eq!(prod.dialect, Dialect::Postgresql);
1689    }
1690
1691    #[test]
1692    fn multi_database_requires_selection() {
1693        let cfg = Config::load_from_str(
1694            r#"
1695            [databases.a]
1696            dialect = "sqlite"
1697            [databases.b]
1698            dialect = "postgresql"
1699        "#,
1700            Path::new("test.toml"),
1701        )
1702        .unwrap();
1703
1704        // Should error when no db specified with multiple dbs
1705        assert!(cfg.database(None).is_err());
1706    }
1707
1708    #[test]
1709    fn env_var_syntax() {
1710        let cfg = Config::load_from_str(
1711            r#"
1712            dialect = "postgresql"
1713            [dbCredentials]
1714            url = { env = "DATABASE_URL" }
1715        "#,
1716            Path::new("test.toml"),
1717        )
1718        .unwrap();
1719        assert!(cfg.is_single_database());
1720    }
1721
1722    #[test]
1723    fn casing_options() {
1724        let cfg = Config::load_from_str(
1725            r#"
1726            dialect = "postgresql"
1727            casing = "snake_case"
1728            [dbCredentials]
1729            url = "postgres://localhost/db"
1730        "#,
1731            Path::new("test.toml"),
1732        )
1733        .unwrap();
1734        let db = cfg.default_database().unwrap();
1735        assert_eq!(db.effective_casing(), Casing::SnakeCase);
1736
1737        // Test default (camelCase)
1738        let cfg2 = Config::load_from_str(
1739            r#"
1740            dialect = "postgresql"
1741            [dbCredentials]
1742            url = "postgres://localhost/db"
1743        "#,
1744            Path::new("test.toml"),
1745        )
1746        .unwrap();
1747        let db2 = cfg2.default_database().unwrap();
1748        assert_eq!(db2.effective_casing(), Casing::CamelCase);
1749    }
1750
1751    #[test]
1752    fn introspect_casing() {
1753        let cfg = Config::load_from_str(
1754            r#"
1755            dialect = "postgresql"
1756            [introspect]
1757            casing = "preserve"
1758            [dbCredentials]
1759            url = "postgres://localhost/db"
1760        "#,
1761            Path::new("test.toml"),
1762        )
1763        .unwrap();
1764        let db = cfg.default_database().unwrap();
1765        assert_eq!(db.effective_introspect_casing(), IntrospectCasing::Preserve);
1766    }
1767
1768    #[test]
1769    fn entities_roles_filter() {
1770        // Test boolean roles filter
1771        let cfg = Config::load_from_str(
1772            r#"
1773            dialect = "postgresql"
1774            [entities]
1775            roles = true
1776            [dbCredentials]
1777            url = "postgres://localhost/db"
1778        "#,
1779            Path::new("test.toml"),
1780        )
1781        .unwrap();
1782        let db = cfg.default_database().unwrap();
1783        assert!(db.roles_enabled());
1784        assert!(db.should_include_role("my_role"));
1785
1786        // Test roles filter with provider
1787        let cfg2 = Config::load_from_str(
1788            r#"
1789            dialect = "postgresql"
1790            [entities.roles]
1791            provider = "supabase"
1792            [dbCredentials]
1793            url = "postgres://localhost/db"
1794        "#,
1795            Path::new("test.toml"),
1796        )
1797        .unwrap();
1798        let db2 = cfg2.default_database().unwrap();
1799        assert!(db2.roles_enabled());
1800        assert!(!db2.should_include_role("anon")); // Supabase built-in
1801        assert!(db2.should_include_role("my_custom_role"));
1802    }
1803
1804    #[test]
1805    fn extensions_filter() {
1806        let cfg = Config::load_from_str(
1807            r#"
1808            dialect = "postgresql"
1809            extensionsFilters = ["postgis"]
1810            [dbCredentials]
1811            url = "postgres://localhost/db"
1812        "#,
1813            Path::new("test.toml"),
1814        )
1815        .unwrap();
1816        let db = cfg.default_database().unwrap();
1817        assert!(db.has_extension(Extension::Postgis));
1818    }
1819
1820    #[test]
1821    fn rejects_postgres_only_filters_for_sqlite() {
1822        let err = Config::load_from_str(
1823            r#"
1824            dialect = "sqlite"
1825            schemaFilter = ["public"]
1826            [dbCredentials]
1827            url = "./dev.db"
1828        "#,
1829            Path::new("test.toml"),
1830        )
1831        .expect_err("sqlite should reject schemaFilter");
1832        assert_eq!(
1833            err.to_string(),
1834            "invalid config: schemaFilter is only supported for dialect = \"postgresql\""
1835        );
1836
1837        let err = Config::load_from_str(
1838            r#"
1839            dialect = "sqlite"
1840            extensionsFilters = ["postgis"]
1841            [dbCredentials]
1842            url = "./dev.db"
1843        "#,
1844            Path::new("test.toml"),
1845        )
1846        .expect_err("sqlite should reject extensionsFilters");
1847        assert_eq!(
1848            err.to_string(),
1849            "invalid config: extensionsFilters is only supported for dialect = \"postgresql\""
1850        );
1851    }
1852
1853    #[test]
1854    fn rejects_entities_filter_for_turso() {
1855        let err = Config::load_from_str(
1856            r#"
1857            dialect = "turso"
1858            [entities]
1859            roles = true
1860            [dbCredentials]
1861            url = "libsql://example.turso.io"
1862        "#,
1863            Path::new("test.toml"),
1864        )
1865        .expect_err("turso should reject entities filter");
1866        assert_eq!(
1867            err.to_string(),
1868            "invalid config: entities filter is only supported for dialect = \"postgresql\""
1869        );
1870    }
1871
1872    #[test]
1873    fn migrations_config() {
1874        let cfg = Config::load_from_str(
1875            r#"
1876            dialect = "postgresql"
1877            [migrations]
1878            table = "custom_migrations"
1879            schema = "custom_schema"
1880            [dbCredentials]
1881            url = "postgres://localhost/db"
1882        "#,
1883            Path::new("test.toml"),
1884        )
1885        .unwrap();
1886        let db = cfg.default_database().unwrap();
1887        assert_eq!(db.migrations_table(), "custom_migrations");
1888        assert_eq!(db.migrations_schema(), "custom_schema");
1889
1890        // Test defaults
1891        let cfg2 = Config::load_from_str(
1892            r#"
1893            dialect = "postgresql"
1894            [dbCredentials]
1895            url = "postgres://localhost/db"
1896        "#,
1897            Path::new("test.toml"),
1898        )
1899        .unwrap();
1900        let db2 = cfg2.default_database().unwrap();
1901        assert_eq!(db2.migrations_table(), "__drizzle_migrations");
1902        assert_eq!(db2.migrations_schema(), "drizzle");
1903    }
1904
1905    #[test]
1906    fn resolves_paths_relative_to_config_dir() {
1907        let tmp = TempDir::new().unwrap();
1908        let cfg_dir = tmp.path().join("cfg");
1909        fs::create_dir_all(&cfg_dir).unwrap();
1910
1911        // Create schema file next to config file.
1912        let schema_path = cfg_dir.join("schema.rs");
1913        fs::write(&schema_path, "#[allow(dead_code)]\npub struct X;").unwrap();
1914
1915        let cfg_path = cfg_dir.join("drizzle.config.toml");
1916        let cfg = Config::load_from_str(
1917            r#"
1918            dialect = "sqlite"
1919            schema = "schema.rs"
1920            out = "./drizzle"
1921            [dbCredentials]
1922            url = "./dev.db"
1923        "#,
1924            &cfg_path,
1925        )
1926        .unwrap();
1927
1928        let db = cfg.default_database().unwrap();
1929        assert_eq!(db.migrations_dir(), cfg_dir.join("./drizzle").as_path());
1930
1931        let files = db.schema_files().unwrap();
1932        assert_eq!(files.len(), 1);
1933        assert_eq!(files[0], schema_path);
1934    }
1935
1936    #[test]
1937    fn rejects_host_credentials_for_sqlite() {
1938        let err = Config::load_from_str(
1939            r#"
1940            dialect = "sqlite"
1941            [dbCredentials]
1942            host = "localhost"
1943            database = "db"
1944        "#,
1945            Path::new("test.toml"),
1946        )
1947        .unwrap_err();
1948
1949        assert_eq!(
1950            err.to_string(),
1951            "invalid credentials: host-based dbCredentials are only supported for dialect = \"postgresql\""
1952        );
1953    }
1954
1955    // ========================================================================
1956    // Cloudflare: D1 HTTP and Durable Objects SQLite
1957    // ========================================================================
1958
1959    #[test]
1960    fn d1_http_credentials_parse() {
1961        let cfg = Config::load_from_str(
1962            r#"
1963            dialect = "sqlite"
1964            driver = "d1-http"
1965            [dbCredentials]
1966            accountId = "acc_abc"
1967            databaseId = "db_xyz"
1968            token = "tok_123"
1969        "#,
1970            Path::new("test.toml"),
1971        )
1972        .unwrap();
1973
1974        let db = cfg.default_database().unwrap();
1975        assert_eq!(db.driver, Some(Driver::D1Http));
1976        match db.credentials().unwrap() {
1977            Some(Credentials::D1 {
1978                account_id,
1979                database_id,
1980                token,
1981            }) => {
1982                assert_eq!(&*account_id, "acc_abc");
1983                assert_eq!(&*database_id, "db_xyz");
1984                assert_eq!(&*token, "tok_123");
1985            }
1986            other => panic!("expected Credentials::D1, got {other:?}"),
1987        }
1988    }
1989
1990    #[test]
1991    fn d1_http_credentials_resolve_from_env() {
1992        // Unique env var names per-test so parallel tests don't collide.
1993        unsafe {
1994            std::env::set_var("TEST_D1_ACCT", "env_acct");
1995            std::env::set_var("TEST_D1_DB", "env_db");
1996            std::env::set_var("TEST_D1_TOKEN", "env_token");
1997        }
1998        let cfg = Config::load_from_str(
1999            r#"
2000            dialect = "sqlite"
2001            driver = "d1-http"
2002            [dbCredentials]
2003            accountId = { env = "TEST_D1_ACCT" }
2004            databaseId = { env = "TEST_D1_DB" }
2005            token = { env = "TEST_D1_TOKEN" }
2006        "#,
2007            Path::new("test.toml"),
2008        )
2009        .unwrap();
2010
2011        match cfg.default_database().unwrap().credentials().unwrap() {
2012            Some(Credentials::D1 {
2013                account_id,
2014                database_id,
2015                token,
2016            }) => {
2017                assert_eq!(&*account_id, "env_acct");
2018                assert_eq!(&*database_id, "env_db");
2019                assert_eq!(&*token, "env_token");
2020            }
2021            other => panic!("expected Credentials::D1, got {other:?}"),
2022        }
2023    }
2024
2025    #[test]
2026    fn d1_credentials_require_sqlite_dialect() {
2027        let err = Config::load_from_str(
2028            r#"
2029            dialect = "postgresql"
2030            [dbCredentials]
2031            accountId = "acc"
2032            databaseId = "db"
2033            token = "tok"
2034        "#,
2035            Path::new("test.toml"),
2036        )
2037        .unwrap_err();
2038        assert!(
2039            err.to_string().contains("D1 dbCredentials"),
2040            "expected D1-specific error, got: {err}"
2041        );
2042    }
2043
2044    #[test]
2045    fn d1_credentials_require_d1_http_driver() {
2046        // Same SQLite dialect, but driver is rusqlite — should be rejected.
2047        let err = Config::load_from_str(
2048            r#"
2049            dialect = "sqlite"
2050            driver = "rusqlite"
2051            [dbCredentials]
2052            accountId = "acc"
2053            databaseId = "db"
2054            token = "tok"
2055        "#,
2056            Path::new("test.toml"),
2057        )
2058        .unwrap_err();
2059        assert!(
2060            err.to_string().contains("driver = \"d1-http\""),
2061            "expected d1-http driver error, got: {err}"
2062        );
2063    }
2064
2065    #[test]
2066    fn d1_http_driver_requires_d1_credentials() {
2067        // Driver is d1-http but creds are URL-shaped — should be rejected.
2068        let err = Config::load_from_str(
2069            r#"
2070            dialect = "sqlite"
2071            driver = "d1-http"
2072            [dbCredentials]
2073            url = "./dev.db"
2074        "#,
2075            Path::new("test.toml"),
2076        )
2077        .unwrap_err();
2078        assert!(
2079            err.to_string().contains("accountId, databaseId, and token"),
2080            "expected d1-http creds-shape error, got: {err}"
2081        );
2082    }
2083
2084    #[test]
2085    fn durable_sqlite_no_credentials_ok() {
2086        // Durable Objects don't need credentials — migrations are applied inside
2087        // the Worker runtime. Loading without dbCredentials should succeed.
2088        let cfg = Config::load_from_str(
2089            r#"
2090            dialect = "sqlite"
2091            driver = "durable-sqlite"
2092        "#,
2093            Path::new("test.toml"),
2094        )
2095        .unwrap();
2096
2097        let db = cfg.default_database().unwrap();
2098        assert_eq!(db.driver, Some(Driver::DurableSqlite));
2099        assert!(db.credentials().unwrap().is_none());
2100        // Bundle should auto-enable so migrations.js gets emitted for the Worker.
2101        assert!(
2102            db.bundle_enabled(),
2103            "durable-sqlite should auto-enable bundle"
2104        );
2105    }
2106
2107    #[test]
2108    fn durable_sqlite_explicit_bundle_false_respected() {
2109        // Explicit opt-out must override the durable-sqlite auto-enable.
2110        let cfg = Config::load_from_str(
2111            r#"
2112            dialect = "sqlite"
2113            driver = "durable-sqlite"
2114            [migrations]
2115            bundle = false
2116        "#,
2117            Path::new("test.toml"),
2118        )
2119        .unwrap();
2120        assert!(!cfg.default_database().unwrap().bundle_enabled());
2121    }
2122
2123    #[test]
2124    fn durable_sqlite_rejects_non_sqlite_dialect() {
2125        let err = Config::load_from_str(
2126            r#"
2127            dialect = "postgresql"
2128            driver = "durable-sqlite"
2129            [dbCredentials]
2130            url = "postgres://localhost/db"
2131        "#,
2132            Path::new("test.toml"),
2133        )
2134        .unwrap_err();
2135        assert!(
2136            err.to_string().contains("invalid for postgresql"),
2137            "expected dialect/driver mismatch error, got: {err}"
2138        );
2139    }
2140
2141    #[test]
2142    fn driver_valid_for_sqlite_includes_cloudflare() {
2143        let drivers = Driver::valid_for(Dialect::Sqlite);
2144        assert!(drivers.contains(&Driver::Rusqlite));
2145        assert!(drivers.contains(&Driver::D1Http));
2146        assert!(drivers.contains(&Driver::DurableSqlite));
2147        // D1/DO must not leak into other dialects.
2148        for drv in [Driver::D1Http, Driver::DurableSqlite] {
2149            assert!(!drv.is_valid_for(Dialect::Postgresql));
2150            assert!(!drv.is_valid_for(Dialect::Turso));
2151        }
2152    }
2153
2154    #[test]
2155    fn driver_is_codegen_only_flag() {
2156        assert!(Driver::DurableSqlite.is_codegen_only());
2157        assert!(!Driver::D1Http.is_codegen_only());
2158        assert!(!Driver::Rusqlite.is_codegen_only());
2159        assert!(!Driver::AwsDataApi.is_codegen_only());
2160    }
2161
2162    // ========================================================================
2163    // AWS RDS Data API (Aurora Serverless PostgreSQL)
2164    // ========================================================================
2165
2166    #[test]
2167    fn aws_data_api_credentials_parse() {
2168        let cfg = Config::load_from_str(
2169            r#"
2170            dialect = "postgresql"
2171            driver = "aws-data-api"
2172            [dbCredentials]
2173            database = "mydb"
2174            secretArn = "arn:aws:secretsmanager:us-east-1:123:secret:db-xyz"
2175            resourceArn = "arn:aws:rds:us-east-1:123:cluster:my-aurora"
2176        "#,
2177            Path::new("test.toml"),
2178        )
2179        .unwrap();
2180
2181        let db = cfg.default_database().unwrap();
2182        assert_eq!(db.driver, Some(Driver::AwsDataApi));
2183        match db.credentials().unwrap() {
2184            Some(Credentials::AwsDataApi {
2185                database,
2186                secret_arn,
2187                resource_arn,
2188            }) => {
2189                assert_eq!(&*database, "mydb");
2190                assert!(secret_arn.starts_with("arn:aws:secretsmanager"));
2191                assert!(resource_arn.starts_with("arn:aws:rds"));
2192            }
2193            other => panic!("expected Credentials::AwsDataApi, got {other:?}"),
2194        }
2195    }
2196
2197    #[test]
2198    fn aws_data_api_credentials_resolve_from_env() {
2199        unsafe {
2200            std::env::set_var("TEST_AWS_DB", "envdb");
2201            std::env::set_var("TEST_AWS_SECRET", "arn:env:secret");
2202            std::env::set_var("TEST_AWS_RESOURCE", "arn:env:resource");
2203        }
2204        let cfg = Config::load_from_str(
2205            r#"
2206            dialect = "postgresql"
2207            driver = "aws-data-api"
2208            [dbCredentials]
2209            database = { env = "TEST_AWS_DB" }
2210            secretArn = { env = "TEST_AWS_SECRET" }
2211            resourceArn = { env = "TEST_AWS_RESOURCE" }
2212        "#,
2213            Path::new("test.toml"),
2214        )
2215        .unwrap();
2216
2217        match cfg.default_database().unwrap().credentials().unwrap() {
2218            Some(Credentials::AwsDataApi {
2219                database,
2220                secret_arn,
2221                resource_arn,
2222            }) => {
2223                assert_eq!(&*database, "envdb");
2224                assert_eq!(&*secret_arn, "arn:env:secret");
2225                assert_eq!(&*resource_arn, "arn:env:resource");
2226            }
2227            other => panic!("expected Credentials::AwsDataApi, got {other:?}"),
2228        }
2229    }
2230
2231    #[test]
2232    fn aws_data_api_requires_postgres_dialect() {
2233        let err = Config::load_from_str(
2234            r#"
2235            dialect = "sqlite"
2236            [dbCredentials]
2237            database = "mydb"
2238            secretArn = "arn:aws:secretsmanager:..."
2239            resourceArn = "arn:aws:rds:..."
2240        "#,
2241            Path::new("test.toml"),
2242        )
2243        .unwrap_err();
2244        assert!(
2245            err.to_string().contains("AWS Data API dbCredentials"),
2246            "expected AWS-specific error, got: {err}"
2247        );
2248    }
2249
2250    #[test]
2251    fn aws_data_api_requires_aws_data_api_driver() {
2252        // Same postgresql dialect, but driver is tokio-postgres — should be rejected.
2253        let err = Config::load_from_str(
2254            r#"
2255            dialect = "postgresql"
2256            driver = "tokio-postgres"
2257            [dbCredentials]
2258            database = "mydb"
2259            secretArn = "arn:aws:secretsmanager:..."
2260            resourceArn = "arn:aws:rds:..."
2261        "#,
2262            Path::new("test.toml"),
2263        )
2264        .unwrap_err();
2265        assert!(
2266            err.to_string().contains("driver = \"aws-data-api\""),
2267            "expected aws-data-api driver error, got: {err}"
2268        );
2269    }
2270
2271    #[test]
2272    fn aws_data_api_driver_requires_aws_credentials() {
2273        // driver = aws-data-api but creds are URL-shaped — should be rejected.
2274        let err = Config::load_from_str(
2275            r#"
2276            dialect = "postgresql"
2277            driver = "aws-data-api"
2278            [dbCredentials]
2279            url = "postgres://localhost/db"
2280        "#,
2281            Path::new("test.toml"),
2282        )
2283        .unwrap_err();
2284        assert!(
2285            err.to_string()
2286                .contains("database, secretArn, and resourceArn"),
2287            "expected aws-data-api creds-shape error, got: {err}"
2288        );
2289    }
2290
2291    #[test]
2292    fn aws_data_api_rejected_for_non_postgres_dialect() {
2293        let err = Config::load_from_str(
2294            r#"
2295            dialect = "sqlite"
2296            driver = "aws-data-api"
2297        "#,
2298            Path::new("test.toml"),
2299        )
2300        .unwrap_err();
2301        assert!(
2302            err.to_string().contains("invalid for sqlite"),
2303            "expected dialect/driver mismatch error, got: {err}"
2304        );
2305    }
2306
2307    #[test]
2308    fn driver_valid_for_postgres_includes_aws_data_api() {
2309        let drivers = Driver::valid_for(Dialect::Postgresql);
2310        assert!(drivers.contains(&Driver::PostgresSync));
2311        assert!(drivers.contains(&Driver::TokioPostgres));
2312        assert!(drivers.contains(&Driver::AwsDataApi));
2313        // Must not leak into other dialects.
2314        assert!(!Driver::AwsDataApi.is_valid_for(Dialect::Sqlite));
2315        assert!(!Driver::AwsDataApi.is_valid_for(Dialect::Turso));
2316    }
2317
2318    #[test]
2319    fn credentials_debug_redacts_secrets() {
2320        let postgres = Credentials::Postgres(PostgresCreds::Host {
2321            host: "localhost".into(),
2322            port: 5432,
2323            user: Some("alice".into()),
2324            password: Some("super-secret-password".into()),
2325            database: "app".into(),
2326            ssl: PostgresSslMode::Require,
2327        });
2328        let turso = Credentials::Turso {
2329            url: "libsql://example.turso.io".into(),
2330            auth_token: Some("super-secret-token".into()),
2331        };
2332        let d1 = Credentials::D1 {
2333            account_id: "account".into(),
2334            database_id: "database".into(),
2335            token: "super-secret-d1-token".into(),
2336        };
2337        let postgres_url = Credentials::Postgres(PostgresCreds::Url(
2338            "postgres://alice:super-secret-url-password@localhost/app".into(),
2339        ));
2340
2341        let rendered = format!("{postgres:?} {turso:?} {d1:?} {postgres_url:?}");
2342        assert!(rendered.contains("[REDACTED]"));
2343        assert!(!rendered.contains("super-secret"));
2344    }
2345
2346    #[cfg(windows)]
2347    #[test]
2348    fn schema_files_accept_backslash_paths() {
2349        let tmp = TempDir::new().unwrap();
2350        let cfg_dir = tmp.path().join("cfg");
2351        fs::create_dir_all(&cfg_dir).unwrap();
2352
2353        let schema_path = cfg_dir.join("src").join("schema.rs");
2354        fs::create_dir_all(schema_path.parent().unwrap()).unwrap();
2355        fs::write(&schema_path, "#[allow(dead_code)]\npub struct X;").unwrap();
2356
2357        // Write schema path with backslashes (common on Windows).
2358        let schema_str = schema_path.to_string_lossy().replace('/', "\\");
2359        // TOML basic strings treat backslash as an escape; double-escape to embed a Windows path.
2360        let schema_toml = schema_str.replace('\\', "\\\\");
2361        let cfg_path = cfg_dir.join("drizzle.config.toml");
2362        let cfg = Config::load_from_str(
2363            &format!(
2364                r#"
2365                dialect = "sqlite"
2366                schema = "{}"
2367            "#,
2368                schema_toml
2369            ),
2370            &cfg_path,
2371        )
2372        .unwrap();
2373
2374        let db = cfg.default_database().unwrap();
2375        let files = db.schema_files().unwrap();
2376        assert_eq!(files, vec![schema_path]);
2377    }
2378}