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
574pub struct PostgresConnectionConfig {
575    pub config: tokio_postgres::Config,
576    pub ssl: PostgresSslMode,
577}
578
579impl PostgresCreds {
580    pub fn connection_config(&self) -> Result<PostgresConnectionConfig, String> {
581        match self {
582            Self::Url(url) => {
583                let config = url
584                    .parse::<tokio_postgres::Config>()
585                    .map_err(|error| error.to_string())?;
586                let ssl = match config.get_ssl_mode() {
587                    tokio_postgres::config::SslMode::Disable => PostgresSslMode::Disable,
588                    tokio_postgres::config::SslMode::Prefer => PostgresSslMode::Prefer,
589                    tokio_postgres::config::SslMode::Require => PostgresSslMode::Require,
590                    _ => PostgresSslMode::Require,
591                };
592                Ok(PostgresConnectionConfig { config, ssl })
593            }
594            Self::Host {
595                host,
596                port,
597                user,
598                password,
599                database,
600                ssl,
601            } => {
602                let mut config = tokio_postgres::Config::new();
603                config
604                    .host(host.as_ref())
605                    .port(*port)
606                    .dbname(database.as_ref());
607                if let Some(user) = user {
608                    config.user(user.as_ref());
609                }
610                if let Some(password) = password {
611                    config.password(password.as_bytes());
612                }
613                config.ssl_mode(match ssl {
614                    PostgresSslMode::Disable => tokio_postgres::config::SslMode::Disable,
615                    PostgresSslMode::Allow | PostgresSslMode::Prefer => {
616                        tokio_postgres::config::SslMode::Prefer
617                    }
618                    PostgresSslMode::Require
619                    | PostgresSslMode::VerifyCa
620                    | PostgresSslMode::VerifyFull => tokio_postgres::config::SslMode::Require,
621                });
622                Ok(PostgresConnectionConfig { config, ssl: *ssl })
623            }
624        }
625    }
626}
627
628// ============================================================================
629// Schema path(s)
630// ============================================================================
631
632/// Schema path(s)
633#[derive(Debug, Clone, Deserialize, JsonSchema)]
634#[serde(untagged)]
635pub enum Schema {
636    One(String),
637    Many(Vec<String>),
638}
639
640impl Default for Schema {
641    fn default() -> Self {
642        Self::One("src/schema.rs".into())
643    }
644}
645
646impl Schema {
647    pub fn iter(&self) -> impl Iterator<Item = &str> {
648        match self {
649            Self::One(s) => std::slice::from_ref(s).iter().map(String::as_str),
650            Self::Many(v) => v.iter().map(String::as_str),
651        }
652    }
653}
654
655/// Filter (single or multiple values)
656#[derive(Debug, Clone, Deserialize, JsonSchema)]
657#[serde(untagged)]
658pub enum Filter {
659    One(String),
660    Many(Vec<String>),
661}
662
663impl Filter {
664    pub fn iter(&self) -> impl Iterator<Item = &str> {
665        match self {
666            Self::One(s) => std::slice::from_ref(s).iter().map(String::as_str),
667            Self::Many(v) => v.iter().map(String::as_str),
668        }
669    }
670}
671
672/// Migration options
673#[derive(Debug, Clone, Deserialize, JsonSchema)]
674pub struct MigrationsOpts {
675    pub table: Option<String>,
676    pub schema: Option<String>,
677    pub prefix: Option<MigrationPrefix>,
678    /// Emit a `migrations.js` index at the root of the migrations output folder.
679    ///
680    /// Matches drizzle-kit's `bundle: true` behavior. The file statically
681    /// `import`s each `migration.sql` so JS bundlers (Metro for Expo/React
682    /// Native, Cloudflare Workers for Durable Objects `SQLite`) can embed the
683    /// SQL text at build time. Harmless for Rust-only consumers.
684    #[serde(default)]
685    pub bundle: Option<bool>,
686}
687
688#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)]
689#[serde(rename_all = "lowercase")]
690pub enum MigrationPrefix {
691    Index,
692    Timestamp,
693    Supabase,
694    Unix,
695    None,
696}
697
698// ============================================================================
699// Raw credentials (serde parsing helper)
700// ============================================================================
701
702#[derive(Debug, Clone, Deserialize, JsonSchema)]
703#[serde(untagged)]
704enum RawCreds {
705    /// Cloudflare D1 HTTP API credentials — `{ accountId, databaseId, token }`.
706    ///
707    /// Listed before the more generic `Url` / `Host` variants so that serde's
708    /// untagged matching prefers the fully-specified D1 shape when all three
709    /// fields are present. (Untagged enums try variants top-to-bottom and pick
710    /// the first that deserializes cleanly.)
711    D1 {
712        #[serde(rename = "accountId")]
713        account_id: EnvOr,
714        #[serde(rename = "databaseId")]
715        database_id: EnvOr,
716        token: EnvOr,
717    },
718    /// AWS RDS Data API credentials — `{ database, secretArn, resourceArn }`.
719    ///
720    /// Also listed before `Url`/`Host` so the fully-specified shape wins in the
721    /// untagged match. Note that `database` is also a field name on the Host
722    /// shape, but the combination with `secretArn` + `resourceArn` uniquely
723    /// identifies this variant.
724    AwsDataApi {
725        database: EnvOr,
726        #[serde(rename = "secretArn")]
727        secret_arn: EnvOr,
728        #[serde(rename = "resourceArn")]
729        resource_arn: EnvOr,
730    },
731    Url {
732        url: EnvOr,
733        #[serde(default, rename = "authToken")]
734        auth_token: Option<EnvOr>,
735    },
736    Host {
737        host: EnvOr,
738        #[serde(default)]
739        port: Option<u16>,
740        #[serde(default)]
741        user: Option<EnvOr>,
742        #[serde(default)]
743        password: Option<EnvOr>,
744        database: EnvOr,
745        #[serde(default)]
746        ssl: Option<SslVal>,
747    },
748}
749
750#[derive(Debug, Clone, Deserialize, JsonSchema)]
751#[serde(untagged)]
752enum SslVal {
753    Bool(bool),
754    Str(String),
755}
756
757impl SslVal {
758    fn mode(&self) -> Result<PostgresSslMode, String> {
759        match self {
760            Self::Bool(false) => Ok(PostgresSslMode::Disable),
761            Self::Bool(true) => Ok(PostgresSslMode::Require),
762            Self::Str(value) => PostgresSslMode::parse(value),
763        }
764    }
765}
766
767// ============================================================================
768// DatabaseConfig - Per-database configuration
769// ============================================================================
770
771/// Configuration for a single database
772///
773/// This structure matches drizzle-kit's config format for compatibility.
774#[derive(Debug, Clone, Deserialize, JsonSchema)]
775#[serde(rename_all = "camelCase")]
776pub struct DatabaseConfig {
777    /// Database dialect (required)
778    pub dialect: Dialect,
779
780    /// Path(s) to schema file(s) - supports glob patterns
781    #[serde(default)]
782    pub schema: Schema,
783
784    /// Output directory for migrations (default: "./drizzle")
785    #[serde(default = "default_out")]
786    pub out: PathBuf,
787
788    /// Whether to use SQL breakpoints in migrations (default: true)
789    #[serde(default = "yes")]
790    pub breakpoints: bool,
791
792    /// Database driver for Rust connections
793    #[serde(default)]
794    pub driver: Option<Driver>,
795
796    /// Database credentials
797    #[serde(default)]
798    db_credentials: Option<RawCreds>,
799
800    /// Table name filter (glob patterns supported)
801    #[serde(default)]
802    pub tables_filter: Option<Filter>,
803
804    /// Schema name filter (`PostgreSQL` only)
805    #[serde(default)]
806    pub schema_filter: Option<Filter>,
807
808    /// Extensions filter (`PostgreSQL` only, e.g., `["postgis"]`)
809    #[serde(default)]
810    pub extensions_filters: Option<Vec<Extension>>,
811
812    /// Entities filter (roles, etc.)
813    #[serde(default)]
814    pub entities: Option<EntitiesFilter>,
815
816    /// Casing mode for generated code
817    #[serde(default)]
818    pub casing: Option<Casing>,
819
820    /// Introspection configuration
821    #[serde(default)]
822    pub introspect: Option<IntrospectConfig>,
823
824    /// Verbose output
825    #[serde(default)]
826    pub verbose: bool,
827
828    /// Migration table configuration
829    #[serde(default)]
830    pub migrations: Option<MigrationsOpts>,
831}
832
833fn default_out() -> PathBuf {
834    PathBuf::from("./drizzle")
835}
836
837const fn yes() -> bool {
838    true
839}
840
841impl DatabaseConfig {
842    fn normalize_paths(&mut self, base_dir: &Path) {
843        // Resolve `out` relative to the config file directory for predictable behavior,
844        // especially when `--config` points at a file outside the current working directory.
845        if self.out.is_relative() {
846            self.out = base_dir.join(&self.out);
847        }
848
849        // Normalize schema patterns:
850        // - Resolve relative patterns relative to config dir
851        // - Use forward slashes to avoid glob escaping issues on Windows
852        let base = base_dir.to_string_lossy().replace('\\', "/");
853        let base = base.trim_end_matches('/').to_string();
854
855        let normalize_one = |p: &str| -> String {
856            let p_trim = p.trim();
857            let is_abs = Path::new(p_trim).is_absolute() || p_trim.starts_with("\\\\");
858            let joined = if is_abs || base.is_empty() || base == "." {
859                p_trim.to_string()
860            } else {
861                format!("{base}/{p_trim}")
862            };
863            joined.replace('\\', "/")
864        };
865
866        match &mut self.schema {
867            Schema::One(p) => *p = normalize_one(p),
868            Schema::Many(v) => {
869                for p in v.iter_mut() {
870                    *p = normalize_one(p);
871                }
872            }
873        }
874    }
875
876    fn validate(&self, name: &str) -> Result<(), Error> {
877        // Check driver compatibility
878        if let Some(d) = self.driver
879            && !d.is_valid_for(self.dialect)
880        {
881            return Err(Error::InvalidDriver {
882                driver: d,
883                dialect: self.dialect,
884            });
885        }
886
887        // Validate credentials if present
888        if let Some(ref raw) = self.db_credentials {
889            self.validate_creds(raw, name)?;
890        }
891
892        // PostgreSQL-only settings
893        if self.dialect != Dialect::Postgresql {
894            if self.schema_filter.is_some() {
895                return Err(Error::InvalidConfig(
896                    "schemaFilter is only supported for dialect = \"postgresql\"".into(),
897                ));
898            }
899            if self.extensions_filters.is_some() {
900                return Err(Error::InvalidConfig(
901                    "extensionsFilters is only supported for dialect = \"postgresql\"".into(),
902                ));
903            }
904            if self.entities.is_some() {
905                return Err(Error::InvalidConfig(
906                    "entities filter is only supported for dialect = \"postgresql\"".into(),
907                ));
908            }
909        }
910
911        Ok(())
912    }
913
914    fn validate_creds(&self, raw: &RawCreds, _name: &str) -> Result<(), Error> {
915        let err = |msg: &str| Error::InvalidCredentials(msg.into());
916
917        // Enforce dialect/shape pairing. Without this, serde can parse a "host" form for
918        // any dialect, and later `credentials()` would silently return None.
919        match (self.dialect, raw) {
920            (Dialect::Postgresql, RawCreds::Host { .. } | RawCreds::Url { .. }) => {}
921            (_, RawCreds::Host { .. }) => {
922                return Err(err(
923                    "host-based dbCredentials are only supported for dialect = \"postgresql\"",
924                ));
925            }
926            _ => {}
927        }
928
929        // D1-specific shape requires dialect=sqlite AND driver=d1-http. Paired
930        // together so users can't accidentally point a rusqlite driver at D1
931        // credentials (or vice versa).
932        if let RawCreds::D1 { .. } = raw {
933            if self.dialect != Dialect::Sqlite {
934                return Err(err(
935                    "D1 dbCredentials (accountId/databaseId/token) require dialect = \"sqlite\"",
936                ));
937            }
938            if self.driver != Some(Driver::D1Http) {
939                return Err(err(
940                    "D1 dbCredentials (accountId/databaseId/token) require driver = \"d1-http\"",
941                ));
942            }
943        }
944
945        // Conversely, if the user picked driver = d1-http but didn't supply the
946        // D1 shape, flag it early — otherwise `credentials()` would silently
947        // return None and the CLI would fail much later with a confusing error.
948        if self.driver == Some(Driver::D1Http) && !matches!(raw, RawCreds::D1 { .. }) {
949            return Err(err(
950                "driver = \"d1-http\" requires dbCredentials with accountId, databaseId, and token",
951            ));
952        }
953
954        // AWS Data API shape requires dialect=postgresql AND driver=aws-data-api.
955        // Matches drizzle-kit's shape exactly: { database, secretArn, resourceArn }.
956        // Region isn't part of the config — the AWS SDK resolves it from env.
957        if let RawCreds::AwsDataApi { .. } = raw {
958            if self.dialect != Dialect::Postgresql {
959                return Err(err(
960                    "AWS Data API dbCredentials (database/secretArn/resourceArn) require dialect = \"postgresql\"",
961                ));
962            }
963            if self.driver != Some(Driver::AwsDataApi) {
964                return Err(err(
965                    "AWS Data API dbCredentials (database/secretArn/resourceArn) require driver = \"aws-data-api\"",
966                ));
967            }
968        }
969
970        // And the inverse — driver=aws-data-api must use the AwsDataApi shape.
971        if self.driver == Some(Driver::AwsDataApi) && !matches!(raw, RawCreds::AwsDataApi { .. }) {
972            return Err(err(
973                "driver = \"aws-data-api\" requires dbCredentials with database, secretArn, and resourceArn",
974            ));
975        }
976
977        // Dialect-specific checks (only for direct values, not env var references)
978        match (self.dialect, raw) {
979            (
980                Dialect::Sqlite,
981                RawCreds::Url {
982                    auth_token: Some(_),
983                    ..
984                },
985            ) => Err(err(
986                "SQLite doesn't support authToken (use dialect = \"turso\")",
987            )),
988            (
989                Dialect::Sqlite,
990                RawCreds::Url {
991                    url: EnvOr::Value(url),
992                    ..
993                },
994            ) if url.starts_with("libsql://") => Err(err(
995                "libsql:// URLs require dialect = \"turso\" (for local SQLite files, use ./path.db)",
996            )),
997            (
998                Dialect::Sqlite,
999                RawCreds::Url {
1000                    url: EnvOr::Value(url),
1001                    ..
1002                },
1003            ) if url.starts_with("http://")
1004                || url.starts_with("https://")
1005                || url.starts_with("postgres://")
1006                || url.starts_with("postgresql://") =>
1007            {
1008                Err(err(
1009                    "SQLite dbCredentials.url must be a local file path (not an http(s)/postgres URL)",
1010                ))
1011            }
1012            (
1013                Dialect::Turso,
1014                RawCreds::Url {
1015                    url: EnvOr::Value(url),
1016                    ..
1017                },
1018            ) if !url.starts_with("libsql://") && !url.starts_with("http") => {
1019                Err(err("Turso URL must start with libsql:// or http(s)://"))
1020            }
1021            (
1022                Dialect::Postgresql,
1023                RawCreds::Url {
1024                    url: EnvOr::Value(url),
1025                    ..
1026                },
1027            ) if !url.starts_with("postgres") => {
1028                Err(err("PostgreSQL URL must start with postgres://"))
1029            }
1030            _ => Ok(()),
1031        }
1032    }
1033
1034    /// Get typed credentials, resolving any environment variable references.
1035    ///
1036    /// # Errors
1037    ///
1038    /// Returns [`Error`] if a referenced environment variable is missing or
1039    /// invalid, or if the credentials block does not match the configured
1040    /// dialect.
1041    pub fn credentials(&self) -> Result<Option<Credentials>, Error> {
1042        let Some(raw) = self.db_credentials.as_ref() else {
1043            return Ok(None);
1044        };
1045
1046        // Helper to resolve an optional EnvOr
1047        let resolve_opt = |opt: &Option<EnvOr>| -> Result<Option<Box<str>>, Error> {
1048            match opt.as_ref() {
1049                None => Ok(None),
1050                Some(e) => Ok(Some(e.resolve()?.into_boxed_str())),
1051            }
1052        };
1053
1054        let creds = match (self.dialect, raw) {
1055            // Cloudflare D1 HTTP — only valid with dialect=sqlite (enforced by
1056            // validate_creds). Keeps the driver field out of this arm since
1057            // validate_creds already guaranteed driver = d1-http.
1058            (
1059                Dialect::Sqlite,
1060                RawCreds::D1 {
1061                    account_id,
1062                    database_id,
1063                    token,
1064                },
1065            ) => Credentials::D1 {
1066                account_id: account_id.resolve()?.into_boxed_str(),
1067                database_id: database_id.resolve()?.into_boxed_str(),
1068                token: token.resolve()?.into_boxed_str(),
1069            },
1070            // AWS RDS Data API — only valid with dialect=postgresql (enforced
1071            // by validate_creds).
1072            (
1073                Dialect::Postgresql,
1074                RawCreds::AwsDataApi {
1075                    database,
1076                    secret_arn,
1077                    resource_arn,
1078                },
1079            ) => Credentials::AwsDataApi {
1080                database: database.resolve()?.into_boxed_str(),
1081                secret_arn: secret_arn.resolve()?.into_boxed_str(),
1082                resource_arn: resource_arn.resolve()?.into_boxed_str(),
1083            },
1084            // SQLite
1085            (Dialect::Sqlite, RawCreds::Url { url, .. }) => Credentials::Sqlite {
1086                path: url.resolve()?.into_boxed_str(),
1087            },
1088            // Turso
1089            (Dialect::Turso, RawCreds::Url { url, auth_token }) => Credentials::Turso {
1090                url: url.resolve()?.into_boxed_str(),
1091                auth_token: resolve_opt(auth_token)?,
1092            },
1093            // PostgreSQL URL
1094            (Dialect::Postgresql, RawCreds::Url { url, .. }) => {
1095                Credentials::Postgres(PostgresCreds::Url(url.resolve()?.into_boxed_str()))
1096            }
1097            // PostgreSQL Host
1098            (
1099                Dialect::Postgresql,
1100                RawCreds::Host {
1101                    host,
1102                    port,
1103                    user,
1104                    password,
1105                    database,
1106                    ssl,
1107                },
1108            ) => Credentials::Postgres(PostgresCreds::Host {
1109                host: host.resolve()?.into_boxed_str(),
1110                port: port.unwrap_or(5432),
1111                user: resolve_opt(user)?,
1112                password: resolve_opt(password)?,
1113                database: database.resolve()?.into_boxed_str(),
1114                ssl: ssl
1115                    .as_ref()
1116                    .map_or(Ok(PostgresSslMode::Disable), SslVal::mode)
1117                    .map_err(Error::InvalidCredentials)?,
1118            }),
1119            _ => return Ok(None),
1120        };
1121
1122        Ok(Some(creds))
1123    }
1124
1125    /// Migrations output directory
1126    #[inline]
1127    #[must_use]
1128    pub fn migrations_dir(&self) -> &Path {
1129        &self.out
1130    }
1131
1132    /// Meta directory (for journal)
1133    #[inline]
1134    #[must_use]
1135    pub fn meta_dir(&self) -> PathBuf {
1136        self.out.join("meta")
1137    }
1138
1139    /// Journal file path
1140    #[inline]
1141    #[must_use]
1142    pub fn journal_path(&self) -> PathBuf {
1143        self.meta_dir().join("_journal.json")
1144    }
1145
1146    /// Schema paths display string
1147    #[must_use]
1148    pub fn schema_display(&self) -> String {
1149        match &self.schema {
1150            Schema::One(s) => s.clone(),
1151            Schema::Many(v) => v.join(", "),
1152        }
1153    }
1154
1155    /// Resolve schema files (with glob support).
1156    ///
1157    /// # Errors
1158    ///
1159    /// Returns [`Error`] if a glob pattern is invalid, if expanding a glob
1160    /// fails, or if the resolved pattern matches zero files.
1161    pub fn schema_files(&self) -> Result<Vec<PathBuf>, Error> {
1162        let mut files = Vec::new();
1163
1164        for pattern in self.schema.iter() {
1165            let pat = pattern.trim();
1166
1167            // If it's not a glob pattern, treat it as a direct path (better Windows behavior).
1168            let is_glob = pat.contains('*') || pat.contains('?') || pat.contains('[');
1169            if !is_glob {
1170                let p = PathBuf::from(pat);
1171                if p.exists() {
1172                    files.push(p);
1173                    continue;
1174                }
1175            }
1176
1177            // Glob patterns: normalize separators to avoid `\` being treated as an escape.
1178            let pat_norm = pat.replace('\\', "/");
1179            match glob::glob(&pat_norm) {
1180                Ok(paths) => {
1181                    let matched: Vec<_> = paths.filter_map(Result::ok).collect();
1182                    if matched.is_empty() && !is_glob {
1183                        let p = PathBuf::from(&pat_norm);
1184                        if p.exists() {
1185                            files.push(p);
1186                        }
1187                    } else {
1188                        files.extend(matched);
1189                    }
1190                }
1191                Err(e) => return Err(Error::Glob(pat.into(), e)),
1192            }
1193        }
1194
1195        // Keep only real files (glob can return directories).
1196        files.retain(|p| p.is_file());
1197        files.sort();
1198        files.dedup();
1199
1200        if files.is_empty() {
1201            return Err(Error::NoSchemaFiles(self.schema_display()));
1202        }
1203
1204        Ok(files)
1205    }
1206
1207    /// Get effective casing mode (default: camelCase)
1208    #[inline]
1209    #[must_use]
1210    pub fn effective_casing(&self) -> Casing {
1211        self.casing.unwrap_or_default()
1212    }
1213
1214    /// Get effective introspect casing mode (default: camel)
1215    #[inline]
1216    #[must_use]
1217    pub fn effective_introspect_casing(&self) -> IntrospectCasing {
1218        self.introspect
1219            .as_ref()
1220            .map(|i| i.casing)
1221            .unwrap_or_default()
1222    }
1223
1224    /// Get entities filter (default: empty)
1225    #[inline]
1226    #[must_use]
1227    pub fn effective_entities(&self) -> EntitiesFilter {
1228        self.entities.clone().unwrap_or_default()
1229    }
1230
1231    /// Check if a role should be included based on entities filter
1232    #[must_use]
1233    pub fn should_include_role(&self, role_name: &str) -> bool {
1234        self.entities
1235            .as_ref()
1236            .is_some_and(|e| e.roles.should_include(role_name))
1237    }
1238
1239    /// Check if roles are enabled in entities filter
1240    #[must_use]
1241    pub fn roles_enabled(&self) -> bool {
1242        self.entities.as_ref().is_some_and(|e| e.roles.is_enabled())
1243    }
1244
1245    /// Get extensions filters (`PostgreSQL` only)
1246    #[must_use]
1247    pub fn extensions(&self) -> &[Extension] {
1248        self.extensions_filters.as_deref().unwrap_or(&[])
1249    }
1250
1251    /// Check if an extension is in the filter list
1252    #[must_use]
1253    pub fn has_extension(&self, ext: Extension) -> bool {
1254        self.extensions_filters
1255            .as_ref()
1256            .is_some_and(|v| v.contains(&ext))
1257    }
1258
1259    /// Get migration table name (default: __`drizzle_migrations`)
1260    #[must_use]
1261    pub fn migrations_table(&self) -> &str {
1262        self.migrations
1263            .as_ref()
1264            .and_then(|m| m.table.as_deref())
1265            .unwrap_or("__drizzle_migrations")
1266    }
1267
1268    /// Get migration schema (`PostgreSQL` only, default: drizzle)
1269    #[must_use]
1270    pub fn migrations_schema(&self) -> &str {
1271        self.migrations
1272            .as_ref()
1273            .and_then(|m| m.schema.as_deref())
1274            .unwrap_or("drizzle")
1275    }
1276
1277    /// Should a bundled `migrations.js` index be emitted alongside `migration.sql`?
1278    ///
1279    /// Resolution order:
1280    /// 1. Explicit `[migrations] bundle = true/false` in the config wins.
1281    /// 2. Otherwise, auto-enable for `driver = "durable-sqlite"` since Durable
1282    ///    Objects need the JS index to import migrations at Worker build time
1283    ///    (there's no other way to ship SQL into a DO).
1284    /// 3. Otherwise, default to `false`.
1285    #[must_use]
1286    pub fn bundle_enabled(&self) -> bool {
1287        if let Some(explicit) = self.migrations.as_ref().and_then(|m| m.bundle) {
1288            return explicit;
1289        }
1290        matches!(self.driver, Some(Driver::DurableSqlite))
1291    }
1292}
1293
1294// ============================================================================
1295// Main Configuration - Wrapper for single/multi-database modes
1296// ============================================================================
1297
1298/// Internal format for multi-database config
1299#[derive(Debug, Clone, Deserialize)]
1300struct MultiDbConfig {
1301    databases: HashMap<String, DatabaseConfig>,
1302}
1303
1304/// Main configuration structure
1305///
1306/// Supports both single-database (legacy) and multi-database configurations:
1307///
1308/// Single database:
1309/// ```toml
1310/// dialect = "sqlite"
1311/// [dbCredentials]
1312/// url = "./dev.db"
1313/// ```
1314///
1315/// Multiple databases:
1316/// ```toml
1317/// [databases.dev]
1318/// dialect = "sqlite"
1319/// [databases.dev.dbCredentials]
1320/// url = "./dev.db"
1321///
1322/// [databases.prod]
1323/// dialect = "postgresql"
1324/// [databases.prod.dbCredentials]
1325/// url = { env = "DATABASE_URL" }
1326/// ```
1327#[derive(Debug, Clone)]
1328pub struct Config {
1329    /// Named database configurations
1330    databases: HashMap<String, DatabaseConfig>,
1331    /// Whether this is a single-database config (for backwards compat)
1332    is_single: bool,
1333}
1334
1335/// Default database name for single-database configs
1336pub const DEFAULT_DB: &str = "default";
1337
1338impl Config {
1339    /// Load from default config file.
1340    ///
1341    /// # Errors
1342    ///
1343    /// Returns [`Error`] if the default config file cannot be read, if it
1344    /// fails to parse as JSON, or if validation of the parsed config fails.
1345    pub fn load() -> Result<Self, Error> {
1346        Self::load_from(Path::new(CONFIG_FILE))
1347    }
1348
1349    /// Load from specific path.
1350    ///
1351    /// # Errors
1352    ///
1353    /// Returns [`Error::NotFound`] if `path` does not exist, [`Error::Io`] for
1354    /// other read errors, and [`Error`] variants for JSON-parse or validation
1355    /// failures.
1356    pub fn load_from(path: &Path) -> Result<Self, Error> {
1357        let content = std::fs::read_to_string(path).map_err(|e| {
1358            if e.kind() == std::io::ErrorKind::NotFound {
1359                Error::NotFound(path.into())
1360            } else {
1361                Error::Io(path.into(), e)
1362            }
1363        })?;
1364
1365        Self::load_from_str(&content, path)
1366    }
1367
1368    /// Load from string content
1369    fn load_from_str(content: &str, path: &Path) -> Result<Self, Error> {
1370        let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
1371
1372        // Try multi-database format first
1373        if let Ok(multi) = toml::from_str::<MultiDbConfig>(content)
1374            && !multi.databases.is_empty()
1375        {
1376            let mut config = Self {
1377                databases: multi.databases,
1378                is_single: false,
1379            };
1380            for db in config.databases.values_mut() {
1381                db.normalize_paths(base_dir);
1382            }
1383            config.validate()?;
1384            return Ok(config);
1385        }
1386
1387        // Fall back to single-database format
1388        let db_config: DatabaseConfig =
1389            toml::from_str(content).map_err(|e| Error::Parse(path.into(), e))?;
1390
1391        let mut databases = HashMap::new();
1392        databases.insert(DEFAULT_DB.to_string(), db_config);
1393
1394        let mut config = Self {
1395            databases,
1396            is_single: true,
1397        };
1398        for db in config.databases.values_mut() {
1399            db.normalize_paths(base_dir);
1400        }
1401        config.validate()?;
1402        Ok(config)
1403    }
1404
1405    fn validate(&self) -> Result<(), Error> {
1406        for (name, db) in &self.databases {
1407            db.validate(name)?;
1408        }
1409        Ok(())
1410    }
1411
1412    /// Check if this is a single-database config
1413    #[must_use]
1414    pub const fn is_single_database(&self) -> bool {
1415        self.is_single
1416    }
1417
1418    /// Get all database names
1419    pub fn database_names(&self) -> impl Iterator<Item = &str> {
1420        self.databases.keys().map(String::as_str)
1421    }
1422
1423    /// Get a specific database config by name.
1424    ///
1425    /// If name is `None`, returns the default/only database.
1426    /// For single-db configs, any name or `None` returns the single database.
1427    ///
1428    /// # Errors
1429    ///
1430    /// Returns [`Error::NoDatabases`] if the config has no databases,
1431    /// [`Error::DatabaseNotFound`] if `name` does not match any configured
1432    /// database, or [`Error::DatabaseRequired`] if multiple databases exist
1433    /// and no name was supplied.
1434    pub fn database(&self, name: Option<&str>) -> Result<&DatabaseConfig, Error> {
1435        name.map_or_else(
1436            || {
1437                // Get default
1438                if self.is_single {
1439                    self.databases.get(DEFAULT_DB).ok_or(Error::NoDatabases)
1440                } else if self.databases.len() == 1 {
1441                    self.databases.values().next().ok_or(Error::NoDatabases)
1442                } else {
1443                    Err(Error::DatabaseRequired(
1444                        self.databases.keys().cloned().collect(),
1445                    ))
1446                }
1447            },
1448            |name| {
1449                if self.is_single {
1450                    // For single-db config, accept any name
1451                    self.databases.get(DEFAULT_DB).ok_or(Error::NoDatabases)
1452                } else {
1453                    self.databases
1454                        .get(name)
1455                        .ok_or_else(|| Error::DatabaseNotFound(name.to_string()))
1456                }
1457            },
1458        )
1459    }
1460
1461    /// Get the default database (for single-db mode or when only one db exists).
1462    ///
1463    /// # Errors
1464    ///
1465    /// Returns the same errors as [`Self::database`] invoked with `None`.
1466    pub fn default_database(&self) -> Result<&DatabaseConfig, Error> {
1467        self.database(None)
1468    }
1469
1470    // ========================================================================
1471    // Backwards compatibility - delegate to default database
1472    // ========================================================================
1473
1474    /// Get dialect (for single-db mode backwards compat)
1475    #[must_use]
1476    pub fn dialect(&self) -> Dialect {
1477        self.default_database()
1478            .map(|d| d.dialect)
1479            .unwrap_or_default()
1480    }
1481
1482    /// Get credentials (for single-db mode backwards compat).
1483    ///
1484    /// # Errors
1485    ///
1486    /// Returns [`Error`] if resolving the default database or its credentials
1487    /// fails (see [`Self::default_database`] and [`DatabaseConfig::credentials`]).
1488    pub fn credentials(&self) -> Result<Option<Credentials>, Error> {
1489        self.default_database()?.credentials()
1490    }
1491
1492    /// Get migrations directory (for single-db mode backwards compat)
1493    #[must_use]
1494    pub fn migrations_dir(&self) -> &Path {
1495        self.default_database()
1496            .map_or_else(|_| Path::new("./drizzle"), |d| d.migrations_dir())
1497    }
1498
1499    /// Get journal path (for single-db mode backwards compat)
1500    #[must_use]
1501    pub fn journal_path(&self) -> PathBuf {
1502        self.default_database().map_or_else(
1503            |_| PathBuf::from("./drizzle/meta/_journal.json"),
1504            DatabaseConfig::journal_path,
1505        )
1506    }
1507
1508    /// Get schema display (for single-db mode backwards compat)
1509    #[must_use]
1510    pub fn schema_display(&self) -> String {
1511        self.default_database()
1512            .map_or_else(|_| "src/schema.rs".into(), DatabaseConfig::schema_display)
1513    }
1514
1515    /// Get schema files (for single-db mode backwards compat).
1516    ///
1517    /// # Errors
1518    ///
1519    /// Returns [`Error`] if resolving the default database fails or if
1520    /// resolving its schema files fails (see [`DatabaseConfig::schema_files`]).
1521    pub fn schema_files(&self) -> Result<Vec<PathBuf>, Error> {
1522        self.default_database()?.schema_files()
1523    }
1524
1525    /// Base dialect for SQL generation (for single-db mode backwards compat)
1526    #[must_use]
1527    pub fn base_dialect(&self) -> drizzle_types::Dialect {
1528        self.dialect().to_base()
1529    }
1530}
1531
1532// ============================================================================
1533// Errors
1534// ============================================================================
1535
1536#[derive(Debug, thiserror::Error)]
1537pub enum Error {
1538    #[error("config not found: {}", .0.display())]
1539    NotFound(PathBuf),
1540
1541    #[error("failed to read {}: {}", .0.display(), .1)]
1542    Io(PathBuf, #[source] std::io::Error),
1543
1544    #[error("failed to parse {}: {}", .0.display(), .1)]
1545    Parse(PathBuf, #[source] toml::de::Error),
1546
1547    #[error("driver '{driver}' invalid for {dialect} dialect")]
1548    InvalidDriver { driver: Driver, dialect: Dialect },
1549
1550    #[error("invalid credentials: {0}")]
1551    InvalidCredentials(String),
1552
1553    #[error("invalid config: {0}")]
1554    InvalidConfig(String),
1555
1556    #[error("invalid glob '{0}': {1}")]
1557    Glob(String, #[source] glob::PatternError),
1558
1559    #[error("no schema files found: {0}")]
1560    NoSchemaFiles(String),
1561
1562    #[error("environment variable '{0}' not found")]
1563    EnvNotFound(String),
1564
1565    #[error("environment variable '{0}' invalid: {1}")]
1566    EnvInvalid(String, String),
1567
1568    #[error("no databases configured")]
1569    NoDatabases,
1570
1571    #[error("database '{0}' not found")]
1572    DatabaseNotFound(String),
1573
1574    #[error("multiple databases configured, use --db to specify: {}", .0.join(", "))]
1575    DatabaseRequired(Vec<String>),
1576}
1577
1578impl From<EnvOrError> for Error {
1579    fn from(err: EnvOrError) -> Self {
1580        match err {
1581            EnvOrError::NotPresent(var) => Self::EnvNotFound(var),
1582            EnvOrError::NotUnicode(var) => Self::EnvInvalid(var, "contains invalid unicode".into()),
1583        }
1584    }
1585}
1586
1587// ============================================================================
1588// Tests
1589// ============================================================================
1590
1591#[cfg(test)]
1592mod tests {
1593    use super::*;
1594    use std::fs;
1595    use tempfile::TempDir;
1596
1597    #[test]
1598    fn sqlite() {
1599        let cfg = Config::load_from_str(
1600            r#"
1601            dialect = "sqlite"
1602            [dbCredentials]
1603            url = "./dev.db"
1604        "#,
1605            Path::new("test.toml"),
1606        )
1607        .unwrap();
1608        assert!(cfg.is_single_database());
1609        assert!(matches!(
1610            cfg.credentials().unwrap(),
1611            Some(Credentials::Sqlite { .. })
1612        ));
1613    }
1614
1615    #[test]
1616    fn postgres_url() {
1617        let cfg = Config::load_from_str(
1618            r#"
1619            dialect = "postgresql"
1620            [dbCredentials]
1621            url = "postgres://localhost/db"
1622        "#,
1623            Path::new("test.toml"),
1624        )
1625        .unwrap();
1626        assert!(matches!(
1627            cfg.credentials().unwrap(),
1628            Some(Credentials::Postgres(PostgresCreds::Url(_)))
1629        ));
1630    }
1631
1632    #[test]
1633    fn postgres_host_credentials_are_structured_without_url_encoding() {
1634        let credentials = PostgresCreds::Host {
1635            host: "db.example.com".into(),
1636            port: 5432,
1637            user: Some("user@tenant".into()),
1638            password: Some("p:a/ss% word".into()),
1639            database: "app/db name".into(),
1640            ssl: PostgresSslMode::VerifyFull,
1641        };
1642
1643        let connection = credentials.connection_config().expect("build config");
1644        assert_eq!(connection.config.get_user(), Some("user@tenant"));
1645        assert_eq!(connection.config.get_password(), Some(&b"p:a/ss% word"[..]));
1646        assert_eq!(connection.config.get_dbname(), Some("app/db name"));
1647        assert_eq!(
1648            connection.config.get_ssl_mode(),
1649            tokio_postgres::config::SslMode::Require
1650        );
1651        assert_eq!(connection.ssl, PostgresSslMode::VerifyFull);
1652    }
1653
1654    #[test]
1655    fn multi_database() {
1656        let cfg = Config::load_from_str(
1657            r#"
1658            [databases.dev]
1659            dialect = "sqlite"
1660            out = "./drizzle/sqlite"
1661            [databases.dev.dbCredentials]
1662            url = "./dev.db"
1663
1664            [databases.prod]
1665            dialect = "postgresql"
1666            out = "./drizzle/postgres"
1667            [databases.prod.dbCredentials]
1668            url = "postgres://localhost/db"
1669        "#,
1670            Path::new("test.toml"),
1671        )
1672        .unwrap();
1673
1674        assert!(!cfg.is_single_database());
1675        let names: Vec<_> = cfg.database_names().collect();
1676        assert!(names.contains(&"dev"));
1677        assert!(names.contains(&"prod"));
1678
1679        let dev = cfg.database(Some("dev")).unwrap();
1680        assert_eq!(dev.dialect, Dialect::Sqlite);
1681
1682        let prod = cfg.database(Some("prod")).unwrap();
1683        assert_eq!(prod.dialect, Dialect::Postgresql);
1684    }
1685
1686    #[test]
1687    fn multi_database_requires_selection() {
1688        let cfg = Config::load_from_str(
1689            r#"
1690            [databases.a]
1691            dialect = "sqlite"
1692            [databases.b]
1693            dialect = "postgresql"
1694        "#,
1695            Path::new("test.toml"),
1696        )
1697        .unwrap();
1698
1699        // Should error when no db specified with multiple dbs
1700        assert!(cfg.database(None).is_err());
1701    }
1702
1703    #[test]
1704    fn env_var_syntax() {
1705        let cfg = Config::load_from_str(
1706            r#"
1707            dialect = "postgresql"
1708            [dbCredentials]
1709            url = { env = "DATABASE_URL" }
1710        "#,
1711            Path::new("test.toml"),
1712        )
1713        .unwrap();
1714        assert!(cfg.is_single_database());
1715    }
1716
1717    #[test]
1718    fn casing_options() {
1719        let cfg = Config::load_from_str(
1720            r#"
1721            dialect = "postgresql"
1722            casing = "snake_case"
1723            [dbCredentials]
1724            url = "postgres://localhost/db"
1725        "#,
1726            Path::new("test.toml"),
1727        )
1728        .unwrap();
1729        let db = cfg.default_database().unwrap();
1730        assert_eq!(db.effective_casing(), Casing::SnakeCase);
1731
1732        // Test default (camelCase)
1733        let cfg2 = Config::load_from_str(
1734            r#"
1735            dialect = "postgresql"
1736            [dbCredentials]
1737            url = "postgres://localhost/db"
1738        "#,
1739            Path::new("test.toml"),
1740        )
1741        .unwrap();
1742        let db2 = cfg2.default_database().unwrap();
1743        assert_eq!(db2.effective_casing(), Casing::CamelCase);
1744    }
1745
1746    #[test]
1747    fn introspect_casing() {
1748        let cfg = Config::load_from_str(
1749            r#"
1750            dialect = "postgresql"
1751            [introspect]
1752            casing = "preserve"
1753            [dbCredentials]
1754            url = "postgres://localhost/db"
1755        "#,
1756            Path::new("test.toml"),
1757        )
1758        .unwrap();
1759        let db = cfg.default_database().unwrap();
1760        assert_eq!(db.effective_introspect_casing(), IntrospectCasing::Preserve);
1761    }
1762
1763    #[test]
1764    fn entities_roles_filter() {
1765        // Test boolean roles filter
1766        let cfg = Config::load_from_str(
1767            r#"
1768            dialect = "postgresql"
1769            [entities]
1770            roles = true
1771            [dbCredentials]
1772            url = "postgres://localhost/db"
1773        "#,
1774            Path::new("test.toml"),
1775        )
1776        .unwrap();
1777        let db = cfg.default_database().unwrap();
1778        assert!(db.roles_enabled());
1779        assert!(db.should_include_role("my_role"));
1780
1781        // Test roles filter with provider
1782        let cfg2 = Config::load_from_str(
1783            r#"
1784            dialect = "postgresql"
1785            [entities.roles]
1786            provider = "supabase"
1787            [dbCredentials]
1788            url = "postgres://localhost/db"
1789        "#,
1790            Path::new("test.toml"),
1791        )
1792        .unwrap();
1793        let db2 = cfg2.default_database().unwrap();
1794        assert!(db2.roles_enabled());
1795        assert!(!db2.should_include_role("anon")); // Supabase built-in
1796        assert!(db2.should_include_role("my_custom_role"));
1797    }
1798
1799    #[test]
1800    fn extensions_filter() {
1801        let cfg = Config::load_from_str(
1802            r#"
1803            dialect = "postgresql"
1804            extensionsFilters = ["postgis"]
1805            [dbCredentials]
1806            url = "postgres://localhost/db"
1807        "#,
1808            Path::new("test.toml"),
1809        )
1810        .unwrap();
1811        let db = cfg.default_database().unwrap();
1812        assert!(db.has_extension(Extension::Postgis));
1813    }
1814
1815    #[test]
1816    fn rejects_postgres_only_filters_for_sqlite() {
1817        let err = Config::load_from_str(
1818            r#"
1819            dialect = "sqlite"
1820            schemaFilter = ["public"]
1821            [dbCredentials]
1822            url = "./dev.db"
1823        "#,
1824            Path::new("test.toml"),
1825        )
1826        .expect_err("sqlite should reject schemaFilter");
1827        assert_eq!(
1828            err.to_string(),
1829            "invalid config: schemaFilter is only supported for dialect = \"postgresql\""
1830        );
1831
1832        let err = Config::load_from_str(
1833            r#"
1834            dialect = "sqlite"
1835            extensionsFilters = ["postgis"]
1836            [dbCredentials]
1837            url = "./dev.db"
1838        "#,
1839            Path::new("test.toml"),
1840        )
1841        .expect_err("sqlite should reject extensionsFilters");
1842        assert_eq!(
1843            err.to_string(),
1844            "invalid config: extensionsFilters is only supported for dialect = \"postgresql\""
1845        );
1846    }
1847
1848    #[test]
1849    fn rejects_entities_filter_for_turso() {
1850        let err = Config::load_from_str(
1851            r#"
1852            dialect = "turso"
1853            [entities]
1854            roles = true
1855            [dbCredentials]
1856            url = "libsql://example.turso.io"
1857        "#,
1858            Path::new("test.toml"),
1859        )
1860        .expect_err("turso should reject entities filter");
1861        assert_eq!(
1862            err.to_string(),
1863            "invalid config: entities filter is only supported for dialect = \"postgresql\""
1864        );
1865    }
1866
1867    #[test]
1868    fn migrations_config() {
1869        let cfg = Config::load_from_str(
1870            r#"
1871            dialect = "postgresql"
1872            [migrations]
1873            table = "custom_migrations"
1874            schema = "custom_schema"
1875            [dbCredentials]
1876            url = "postgres://localhost/db"
1877        "#,
1878            Path::new("test.toml"),
1879        )
1880        .unwrap();
1881        let db = cfg.default_database().unwrap();
1882        assert_eq!(db.migrations_table(), "custom_migrations");
1883        assert_eq!(db.migrations_schema(), "custom_schema");
1884
1885        // Test defaults
1886        let cfg2 = Config::load_from_str(
1887            r#"
1888            dialect = "postgresql"
1889            [dbCredentials]
1890            url = "postgres://localhost/db"
1891        "#,
1892            Path::new("test.toml"),
1893        )
1894        .unwrap();
1895        let db2 = cfg2.default_database().unwrap();
1896        assert_eq!(db2.migrations_table(), "__drizzle_migrations");
1897        assert_eq!(db2.migrations_schema(), "drizzle");
1898    }
1899
1900    #[test]
1901    fn resolves_paths_relative_to_config_dir() {
1902        let tmp = TempDir::new().unwrap();
1903        let cfg_dir = tmp.path().join("cfg");
1904        fs::create_dir_all(&cfg_dir).unwrap();
1905
1906        // Create schema file next to config file.
1907        let schema_path = cfg_dir.join("schema.rs");
1908        fs::write(&schema_path, "#[allow(dead_code)]\npub struct X;").unwrap();
1909
1910        let cfg_path = cfg_dir.join("drizzle.config.toml");
1911        let cfg = Config::load_from_str(
1912            r#"
1913            dialect = "sqlite"
1914            schema = "schema.rs"
1915            out = "./drizzle"
1916            [dbCredentials]
1917            url = "./dev.db"
1918        "#,
1919            &cfg_path,
1920        )
1921        .unwrap();
1922
1923        let db = cfg.default_database().unwrap();
1924        assert_eq!(db.migrations_dir(), cfg_dir.join("./drizzle").as_path());
1925
1926        let files = db.schema_files().unwrap();
1927        assert_eq!(files.len(), 1);
1928        assert_eq!(files[0], schema_path);
1929    }
1930
1931    #[test]
1932    fn rejects_host_credentials_for_sqlite() {
1933        let err = Config::load_from_str(
1934            r#"
1935            dialect = "sqlite"
1936            [dbCredentials]
1937            host = "localhost"
1938            database = "db"
1939        "#,
1940            Path::new("test.toml"),
1941        )
1942        .unwrap_err();
1943
1944        assert_eq!(
1945            err.to_string(),
1946            "invalid credentials: host-based dbCredentials are only supported for dialect = \"postgresql\""
1947        );
1948    }
1949
1950    // ========================================================================
1951    // Cloudflare: D1 HTTP and Durable Objects SQLite
1952    // ========================================================================
1953
1954    #[test]
1955    fn d1_http_credentials_parse() {
1956        let cfg = Config::load_from_str(
1957            r#"
1958            dialect = "sqlite"
1959            driver = "d1-http"
1960            [dbCredentials]
1961            accountId = "acc_abc"
1962            databaseId = "db_xyz"
1963            token = "tok_123"
1964        "#,
1965            Path::new("test.toml"),
1966        )
1967        .unwrap();
1968
1969        let db = cfg.default_database().unwrap();
1970        assert_eq!(db.driver, Some(Driver::D1Http));
1971        match db.credentials().unwrap() {
1972            Some(Credentials::D1 {
1973                account_id,
1974                database_id,
1975                token,
1976            }) => {
1977                assert_eq!(&*account_id, "acc_abc");
1978                assert_eq!(&*database_id, "db_xyz");
1979                assert_eq!(&*token, "tok_123");
1980            }
1981            other => panic!("expected Credentials::D1, got {other:?}"),
1982        }
1983    }
1984
1985    #[test]
1986    fn d1_http_credentials_resolve_from_env() {
1987        // Unique env var names per-test so parallel tests don't collide.
1988        unsafe {
1989            std::env::set_var("TEST_D1_ACCT", "env_acct");
1990            std::env::set_var("TEST_D1_DB", "env_db");
1991            std::env::set_var("TEST_D1_TOKEN", "env_token");
1992        }
1993        let cfg = Config::load_from_str(
1994            r#"
1995            dialect = "sqlite"
1996            driver = "d1-http"
1997            [dbCredentials]
1998            accountId = { env = "TEST_D1_ACCT" }
1999            databaseId = { env = "TEST_D1_DB" }
2000            token = { env = "TEST_D1_TOKEN" }
2001        "#,
2002            Path::new("test.toml"),
2003        )
2004        .unwrap();
2005
2006        match cfg.default_database().unwrap().credentials().unwrap() {
2007            Some(Credentials::D1 {
2008                account_id,
2009                database_id,
2010                token,
2011            }) => {
2012                assert_eq!(&*account_id, "env_acct");
2013                assert_eq!(&*database_id, "env_db");
2014                assert_eq!(&*token, "env_token");
2015            }
2016            other => panic!("expected Credentials::D1, got {other:?}"),
2017        }
2018    }
2019
2020    #[test]
2021    fn d1_credentials_require_sqlite_dialect() {
2022        let err = Config::load_from_str(
2023            r#"
2024            dialect = "postgresql"
2025            [dbCredentials]
2026            accountId = "acc"
2027            databaseId = "db"
2028            token = "tok"
2029        "#,
2030            Path::new("test.toml"),
2031        )
2032        .unwrap_err();
2033        assert!(
2034            err.to_string().contains("D1 dbCredentials"),
2035            "expected D1-specific error, got: {err}"
2036        );
2037    }
2038
2039    #[test]
2040    fn d1_credentials_require_d1_http_driver() {
2041        // Same SQLite dialect, but driver is rusqlite — should be rejected.
2042        let err = Config::load_from_str(
2043            r#"
2044            dialect = "sqlite"
2045            driver = "rusqlite"
2046            [dbCredentials]
2047            accountId = "acc"
2048            databaseId = "db"
2049            token = "tok"
2050        "#,
2051            Path::new("test.toml"),
2052        )
2053        .unwrap_err();
2054        assert!(
2055            err.to_string().contains("driver = \"d1-http\""),
2056            "expected d1-http driver error, got: {err}"
2057        );
2058    }
2059
2060    #[test]
2061    fn d1_http_driver_requires_d1_credentials() {
2062        // Driver is d1-http but creds are URL-shaped — should be rejected.
2063        let err = Config::load_from_str(
2064            r#"
2065            dialect = "sqlite"
2066            driver = "d1-http"
2067            [dbCredentials]
2068            url = "./dev.db"
2069        "#,
2070            Path::new("test.toml"),
2071        )
2072        .unwrap_err();
2073        assert!(
2074            err.to_string().contains("accountId, databaseId, and token"),
2075            "expected d1-http creds-shape error, got: {err}"
2076        );
2077    }
2078
2079    #[test]
2080    fn durable_sqlite_no_credentials_ok() {
2081        // Durable Objects don't need credentials — migrations are applied inside
2082        // the Worker runtime. Loading without dbCredentials should succeed.
2083        let cfg = Config::load_from_str(
2084            r#"
2085            dialect = "sqlite"
2086            driver = "durable-sqlite"
2087        "#,
2088            Path::new("test.toml"),
2089        )
2090        .unwrap();
2091
2092        let db = cfg.default_database().unwrap();
2093        assert_eq!(db.driver, Some(Driver::DurableSqlite));
2094        assert!(db.credentials().unwrap().is_none());
2095        // Bundle should auto-enable so migrations.js gets emitted for the Worker.
2096        assert!(
2097            db.bundle_enabled(),
2098            "durable-sqlite should auto-enable bundle"
2099        );
2100    }
2101
2102    #[test]
2103    fn durable_sqlite_explicit_bundle_false_respected() {
2104        // Explicit opt-out must override the durable-sqlite auto-enable.
2105        let cfg = Config::load_from_str(
2106            r#"
2107            dialect = "sqlite"
2108            driver = "durable-sqlite"
2109            [migrations]
2110            bundle = false
2111        "#,
2112            Path::new("test.toml"),
2113        )
2114        .unwrap();
2115        assert!(!cfg.default_database().unwrap().bundle_enabled());
2116    }
2117
2118    #[test]
2119    fn durable_sqlite_rejects_non_sqlite_dialect() {
2120        let err = Config::load_from_str(
2121            r#"
2122            dialect = "postgresql"
2123            driver = "durable-sqlite"
2124            [dbCredentials]
2125            url = "postgres://localhost/db"
2126        "#,
2127            Path::new("test.toml"),
2128        )
2129        .unwrap_err();
2130        assert!(
2131            err.to_string().contains("invalid for postgresql"),
2132            "expected dialect/driver mismatch error, got: {err}"
2133        );
2134    }
2135
2136    #[test]
2137    fn driver_valid_for_sqlite_includes_cloudflare() {
2138        let drivers = Driver::valid_for(Dialect::Sqlite);
2139        assert!(drivers.contains(&Driver::Rusqlite));
2140        assert!(drivers.contains(&Driver::D1Http));
2141        assert!(drivers.contains(&Driver::DurableSqlite));
2142        // D1/DO must not leak into other dialects.
2143        for drv in [Driver::D1Http, Driver::DurableSqlite] {
2144            assert!(!drv.is_valid_for(Dialect::Postgresql));
2145            assert!(!drv.is_valid_for(Dialect::Turso));
2146        }
2147    }
2148
2149    #[test]
2150    fn driver_is_codegen_only_flag() {
2151        assert!(Driver::DurableSqlite.is_codegen_only());
2152        assert!(!Driver::D1Http.is_codegen_only());
2153        assert!(!Driver::Rusqlite.is_codegen_only());
2154        assert!(!Driver::AwsDataApi.is_codegen_only());
2155    }
2156
2157    // ========================================================================
2158    // AWS RDS Data API (Aurora Serverless PostgreSQL)
2159    // ========================================================================
2160
2161    #[test]
2162    fn aws_data_api_credentials_parse() {
2163        let cfg = Config::load_from_str(
2164            r#"
2165            dialect = "postgresql"
2166            driver = "aws-data-api"
2167            [dbCredentials]
2168            database = "mydb"
2169            secretArn = "arn:aws:secretsmanager:us-east-1:123:secret:db-xyz"
2170            resourceArn = "arn:aws:rds:us-east-1:123:cluster:my-aurora"
2171        "#,
2172            Path::new("test.toml"),
2173        )
2174        .unwrap();
2175
2176        let db = cfg.default_database().unwrap();
2177        assert_eq!(db.driver, Some(Driver::AwsDataApi));
2178        match db.credentials().unwrap() {
2179            Some(Credentials::AwsDataApi {
2180                database,
2181                secret_arn,
2182                resource_arn,
2183            }) => {
2184                assert_eq!(&*database, "mydb");
2185                assert!(secret_arn.starts_with("arn:aws:secretsmanager"));
2186                assert!(resource_arn.starts_with("arn:aws:rds"));
2187            }
2188            other => panic!("expected Credentials::AwsDataApi, got {other:?}"),
2189        }
2190    }
2191
2192    #[test]
2193    fn aws_data_api_credentials_resolve_from_env() {
2194        unsafe {
2195            std::env::set_var("TEST_AWS_DB", "envdb");
2196            std::env::set_var("TEST_AWS_SECRET", "arn:env:secret");
2197            std::env::set_var("TEST_AWS_RESOURCE", "arn:env:resource");
2198        }
2199        let cfg = Config::load_from_str(
2200            r#"
2201            dialect = "postgresql"
2202            driver = "aws-data-api"
2203            [dbCredentials]
2204            database = { env = "TEST_AWS_DB" }
2205            secretArn = { env = "TEST_AWS_SECRET" }
2206            resourceArn = { env = "TEST_AWS_RESOURCE" }
2207        "#,
2208            Path::new("test.toml"),
2209        )
2210        .unwrap();
2211
2212        match cfg.default_database().unwrap().credentials().unwrap() {
2213            Some(Credentials::AwsDataApi {
2214                database,
2215                secret_arn,
2216                resource_arn,
2217            }) => {
2218                assert_eq!(&*database, "envdb");
2219                assert_eq!(&*secret_arn, "arn:env:secret");
2220                assert_eq!(&*resource_arn, "arn:env:resource");
2221            }
2222            other => panic!("expected Credentials::AwsDataApi, got {other:?}"),
2223        }
2224    }
2225
2226    #[test]
2227    fn aws_data_api_requires_postgres_dialect() {
2228        let err = Config::load_from_str(
2229            r#"
2230            dialect = "sqlite"
2231            [dbCredentials]
2232            database = "mydb"
2233            secretArn = "arn:aws:secretsmanager:..."
2234            resourceArn = "arn:aws:rds:..."
2235        "#,
2236            Path::new("test.toml"),
2237        )
2238        .unwrap_err();
2239        assert!(
2240            err.to_string().contains("AWS Data API dbCredentials"),
2241            "expected AWS-specific error, got: {err}"
2242        );
2243    }
2244
2245    #[test]
2246    fn aws_data_api_requires_aws_data_api_driver() {
2247        // Same postgresql dialect, but driver is tokio-postgres — should be rejected.
2248        let err = Config::load_from_str(
2249            r#"
2250            dialect = "postgresql"
2251            driver = "tokio-postgres"
2252            [dbCredentials]
2253            database = "mydb"
2254            secretArn = "arn:aws:secretsmanager:..."
2255            resourceArn = "arn:aws:rds:..."
2256        "#,
2257            Path::new("test.toml"),
2258        )
2259        .unwrap_err();
2260        assert!(
2261            err.to_string().contains("driver = \"aws-data-api\""),
2262            "expected aws-data-api driver error, got: {err}"
2263        );
2264    }
2265
2266    #[test]
2267    fn aws_data_api_driver_requires_aws_credentials() {
2268        // driver = aws-data-api but creds are URL-shaped — should be rejected.
2269        let err = Config::load_from_str(
2270            r#"
2271            dialect = "postgresql"
2272            driver = "aws-data-api"
2273            [dbCredentials]
2274            url = "postgres://localhost/db"
2275        "#,
2276            Path::new("test.toml"),
2277        )
2278        .unwrap_err();
2279        assert!(
2280            err.to_string()
2281                .contains("database, secretArn, and resourceArn"),
2282            "expected aws-data-api creds-shape error, got: {err}"
2283        );
2284    }
2285
2286    #[test]
2287    fn aws_data_api_rejected_for_non_postgres_dialect() {
2288        let err = Config::load_from_str(
2289            r#"
2290            dialect = "sqlite"
2291            driver = "aws-data-api"
2292        "#,
2293            Path::new("test.toml"),
2294        )
2295        .unwrap_err();
2296        assert!(
2297            err.to_string().contains("invalid for sqlite"),
2298            "expected dialect/driver mismatch error, got: {err}"
2299        );
2300    }
2301
2302    #[test]
2303    fn driver_valid_for_postgres_includes_aws_data_api() {
2304        let drivers = Driver::valid_for(Dialect::Postgresql);
2305        assert!(drivers.contains(&Driver::PostgresSync));
2306        assert!(drivers.contains(&Driver::TokioPostgres));
2307        assert!(drivers.contains(&Driver::AwsDataApi));
2308        // Must not leak into other dialects.
2309        assert!(!Driver::AwsDataApi.is_valid_for(Dialect::Sqlite));
2310        assert!(!Driver::AwsDataApi.is_valid_for(Dialect::Turso));
2311    }
2312
2313    #[test]
2314    fn credentials_debug_redacts_secrets() {
2315        let postgres = Credentials::Postgres(PostgresCreds::Host {
2316            host: "localhost".into(),
2317            port: 5432,
2318            user: Some("alice".into()),
2319            password: Some("super-secret-password".into()),
2320            database: "app".into(),
2321            ssl: PostgresSslMode::Require,
2322        });
2323        let turso = Credentials::Turso {
2324            url: "libsql://example.turso.io".into(),
2325            auth_token: Some("super-secret-token".into()),
2326        };
2327        let d1 = Credentials::D1 {
2328            account_id: "account".into(),
2329            database_id: "database".into(),
2330            token: "super-secret-d1-token".into(),
2331        };
2332        let postgres_url = Credentials::Postgres(PostgresCreds::Url(
2333            "postgres://alice:super-secret-url-password@localhost/app".into(),
2334        ));
2335
2336        let rendered = format!("{postgres:?} {turso:?} {d1:?} {postgres_url:?}");
2337        assert!(rendered.contains("[REDACTED]"));
2338        assert!(!rendered.contains("super-secret"));
2339    }
2340
2341    #[cfg(windows)]
2342    #[test]
2343    fn schema_files_accept_backslash_paths() {
2344        let tmp = TempDir::new().unwrap();
2345        let cfg_dir = tmp.path().join("cfg");
2346        fs::create_dir_all(&cfg_dir).unwrap();
2347
2348        let schema_path = cfg_dir.join("src").join("schema.rs");
2349        fs::create_dir_all(schema_path.parent().unwrap()).unwrap();
2350        fs::write(&schema_path, "#[allow(dead_code)]\npub struct X;").unwrap();
2351
2352        // Write schema path with backslashes (common on Windows).
2353        let schema_str = schema_path.to_string_lossy().replace('/', "\\");
2354        // TOML basic strings treat backslash as an escape; double-escape to embed a Windows path.
2355        let schema_toml = schema_str.replace('\\', "\\\\");
2356        let cfg_path = cfg_dir.join("drizzle.config.toml");
2357        let cfg = Config::load_from_str(
2358            &format!(
2359                r#"
2360                dialect = "sqlite"
2361                schema = "{}"
2362            "#,
2363                schema_toml
2364            ),
2365            &cfg_path,
2366        )
2367        .unwrap();
2368
2369        let db = cfg.default_database().unwrap();
2370        let files = db.schema_files().unwrap();
2371        assert_eq!(files, vec![schema_path]);
2372    }
2373}