Skip to main content

rustauth_cli/
db.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use rustauth_core::db::{DbAdapter, DbSchema, SchemaMigrationPlan, SchemaMigrationWarning};
5use rustauth_core::error::RustAuthError;
6#[cfg(feature = "deadpool-postgres")]
7use rustauth_deadpool_postgres::DeadpoolPostgresAdapter;
8#[cfg(feature = "diesel")]
9use rustauth_diesel::{DieselMysqlAdapter, DieselPostgresAdapter};
10#[cfg(feature = "sqlx")]
11use rustauth_sqlx::{MySqlAdapter, PostgresAdapter, SqliteAdapter};
12#[cfg(feature = "tokio-postgres")]
13use rustauth_tokio_postgres::TokioPostgresAdapter;
14use serde::Serialize;
15use sha2::{Digest, Sha256};
16use time::format_description::well_known::Rfc3339;
17use time::OffsetDateTime;
18
19use crate::config::CliConfig;
20use crate::plugins::plugin_migrations_for_config;
21use crate::schema::{dialect_from_provider, dialect_name, full_schema_plan, target_schema};
22
23pub fn is_cli_migration_adapter(adapter: &str) -> bool {
24    match adapter {
25        "sqlx" if cfg!(feature = "sqlx") => true,
26        "tokio-postgres" if cfg!(feature = "tokio-postgres") => true,
27        "deadpool-postgres" if cfg!(feature = "deadpool-postgres") => true,
28        "diesel" if cfg!(feature = "diesel") => true,
29        _ => false,
30    }
31}
32
33pub fn is_known_cli_migration_adapter(adapter: &str) -> bool {
34    matches!(
35        adapter,
36        "sqlx" | "tokio-postgres" | "deadpool-postgres" | "diesel"
37    )
38}
39
40fn is_adapter_feature_disabled(adapter: &str) -> bool {
41    is_known_cli_migration_adapter(adapter) && !is_cli_migration_adapter(adapter)
42}
43
44pub fn cli_migration_adapter_names() -> Vec<&'static str> {
45    let mut adapters = Vec::new();
46    if cfg!(feature = "sqlx") {
47        adapters.push("sqlx");
48    }
49    if cfg!(feature = "tokio-postgres") {
50        adapters.push("tokio-postgres");
51    }
52    if cfg!(feature = "deadpool-postgres") {
53        adapters.push("deadpool-postgres");
54    }
55    if cfg!(feature = "diesel") {
56        adapters.push("diesel");
57    }
58    adapters
59}
60
61fn is_postgres_provider(provider: &str) -> bool {
62    matches!(provider, "postgres" | "postgresql" | "pg")
63}
64
65fn is_mysql_provider(provider: &str) -> bool {
66    provider == "mysql"
67}
68
69#[derive(Debug, thiserror::Error)]
70pub enum DbCliError {
71    #[error("database provider is not configured")]
72    MissingProvider,
73    #[error(
74        "database.adapter is required; set it explicitly in rustauth.toml \
75         (e.g. sqlx, diesel, tokio-postgres, deadpool-postgres)"
76    )]
77    MissingAdapter,
78    #[error("database URL environment variable {0} is not set; add it to .env/.env.local next to the project or config file, or export it before running this command")]
79    MissingDatabaseUrl(String),
80    #[error(
81        "unsupported database adapter `{adapter}`; {support}",
82        adapter = .0,
83        support = unsupported_adapter_support_suffix()
84    )]
85    UnsupportedAdapter(String),
86    #[error(
87        "database adapter `{0}` is not enabled in this CLI build; rebuild with the matching \
88         Cargo feature ({1})"
89    )]
90    AdapterFeatureDisabled(String, String),
91    #[error("unsupported database provider `{0}`")]
92    UnsupportedProvider(String),
93    #[error("migration has non-executable warnings; fix schema mismatches before applying")]
94    UnsafeMigration,
95    #[error("A migration for this plan already exists: {0}")]
96    DuplicateMigration(String),
97    #[error("database error: {0}")]
98    RustAuth(#[from] RustAuthError),
99    #[error("failed to write {path}: {source}")]
100    Write {
101        path: PathBuf,
102        source: std::io::Error,
103    },
104    #[error("failed to read {path}: {source}")]
105    Read {
106        path: PathBuf,
107        source: std::io::Error,
108    },
109    #[error("failed to create {path}: {source}")]
110    CreateDir {
111        path: PathBuf,
112        source: std::io::Error,
113    },
114    #[error("failed to format timestamp: {0}")]
115    TimeFormat(#[from] time::error::Format),
116}
117
118#[derive(Debug, Clone, Serialize)]
119pub struct PlanSummary {
120    pub provider: String,
121    pub tables_to_create: usize,
122    pub columns_to_add: usize,
123    pub indexes_to_create: usize,
124    pub warnings: Vec<SchemaMigrationWarning>,
125    pub statements: usize,
126    pub plan_hash: String,
127}
128
129#[derive(Debug, Clone)]
130pub struct PlannedMigration {
131    pub schema: DbSchema,
132    pub plan: SchemaMigrationPlan,
133    pub provider: String,
134}
135
136impl PlannedMigration {
137    pub fn summary(&self) -> PlanSummary {
138        PlanSummary {
139            provider: self.provider.clone(),
140            tables_to_create: self.plan.to_be_created.len(),
141            columns_to_add: self.plan.to_be_added.len(),
142            indexes_to_create: self.plan.indexes_to_be_created.len(),
143            warnings: self.plan.warnings.clone(),
144            statements: self.plan.statements.len(),
145            plan_hash: plan_hash(&self.plan),
146        }
147    }
148}
149
150pub async fn plan(config: &CliConfig, from_empty: bool) -> Result<PlannedMigration, DbCliError> {
151    plan_with_base(config, from_empty, None).await
152}
153
154pub async fn plan_with_base(
155    config: &CliConfig,
156    from_empty: bool,
157    cwd: Option<&Path>,
158) -> Result<PlannedMigration, DbCliError> {
159    validate_cli_migration_adapter(config)?;
160    let adapter = required_database_adapter(config)?;
161    let schema = target_schema(config)?;
162    let provider = config
163        .database
164        .provider
165        .clone()
166        .ok_or(DbCliError::MissingProvider)?;
167
168    let plan = if from_empty {
169        let dialect = dialect_from_provider(&provider)
170            .ok_or_else(|| DbCliError::UnsupportedProvider(provider.clone()))?;
171        full_schema_plan(dialect, &schema)?
172    } else {
173        let database_url = database_url_with_base(config, cwd)?;
174        match adapter {
175            #[cfg(feature = "sqlx")]
176            "sqlx" => plan_with_sqlx(&provider, &database_url, &schema).await?,
177            #[cfg(feature = "tokio-postgres")]
178            "tokio-postgres" => {
179                if !is_postgres_provider(&provider) {
180                    return Err(DbCliError::UnsupportedProvider(provider));
181                }
182                TokioPostgresAdapter::connect_with_schema(&database_url, schema.clone())
183                    .await?
184                    .plan_migrations(&schema)
185                    .await?
186            }
187            #[cfg(feature = "deadpool-postgres")]
188            "deadpool-postgres" => {
189                if !is_postgres_provider(&provider) {
190                    return Err(DbCliError::UnsupportedProvider(provider));
191                }
192                DeadpoolPostgresAdapter::builder()
193                    .database_url(database_url)
194                    .schema(schema.clone())
195                    .connect()
196                    .await?
197                    .plan_migrations(&schema)
198                    .await?
199            }
200            #[cfg(feature = "diesel")]
201            "diesel" => plan_with_diesel(&provider, &database_url, &schema).await?,
202            adapter => return Err(adapter_dispatch_error(adapter)),
203        }
204    };
205
206    Ok(PlannedMigration {
207        schema,
208        plan,
209        provider,
210    })
211}
212
213pub async fn migrate(config: &CliConfig) -> Result<PlannedMigration, DbCliError> {
214    migrate_with_base(config, None).await
215}
216
217pub async fn migrate_with_base(
218    config: &CliConfig,
219    cwd: Option<&Path>,
220) -> Result<PlannedMigration, DbCliError> {
221    let planned = plan_with_base(config, false, cwd).await?;
222    if !planned.plan.warnings.is_empty() {
223        return Err(DbCliError::UnsafeMigration);
224    }
225    let database_url = database_url_with_base(config, cwd)?;
226    let plugin_migrations = plugin_migrations_for_config(&config.plugins.enabled)?;
227    let adapter = required_database_adapter(config)?;
228    match adapter {
229        #[cfg(feature = "sqlx")]
230        "sqlx" => {
231            run_migrations_with_sqlx(
232                &planned.provider,
233                &database_url,
234                &planned.schema,
235                &plugin_migrations,
236            )
237            .await?;
238        }
239        #[cfg(feature = "tokio-postgres")]
240        "tokio-postgres" => {
241            let adapter =
242                TokioPostgresAdapter::connect_with_schema(&database_url, planned.schema.clone())
243                    .await?;
244            adapter.run_migrations(&planned.schema).await?;
245            adapter.run_plugin_migrations(&plugin_migrations).await?;
246        }
247        #[cfg(feature = "deadpool-postgres")]
248        "deadpool-postgres" => {
249            let adapter = DeadpoolPostgresAdapter::builder()
250                .database_url(database_url)
251                .schema(planned.schema.clone())
252                .connect()
253                .await?;
254            adapter.run_migrations(&planned.schema).await?;
255            adapter.run_plugin_migrations(&plugin_migrations).await?;
256        }
257        #[cfg(feature = "diesel")]
258        "diesel" => {
259            run_migrations_with_diesel(
260                &planned.provider,
261                &database_url,
262                &planned.schema,
263                &plugin_migrations,
264            )
265            .await?;
266        }
267        adapter => return Err(adapter_dispatch_error(adapter)),
268    }
269    Ok(planned)
270}
271
272#[cfg(feature = "sqlx")]
273async fn plan_with_sqlx(
274    provider: &str,
275    database_url: &str,
276    schema: &DbSchema,
277) -> Result<SchemaMigrationPlan, DbCliError> {
278    match provider {
279        "sqlite" | "sqlite3" => {
280            ensure_sqlite_database(database_url)?;
281            SqliteAdapter::connect_with_schema(database_url, schema.clone())
282                .await?
283                .plan_migrations(schema)
284                .await
285                .map_err(Into::into)
286        }
287        "postgres" | "postgresql" | "pg" => {
288            PostgresAdapter::connect_with_schema(database_url, schema.clone())
289                .await?
290                .plan_migrations(schema)
291                .await
292                .map_err(Into::into)
293        }
294        "mysql" => MySqlAdapter::connect_with_schema(database_url, schema.clone())
295            .await?
296            .plan_migrations(schema)
297            .await
298            .map_err(Into::into),
299        other => Err(DbCliError::UnsupportedProvider(other.to_owned())),
300    }
301}
302
303#[cfg(feature = "sqlx")]
304async fn run_migrations_with_sqlx(
305    provider: &str,
306    database_url: &str,
307    schema: &DbSchema,
308    plugin_migrations: &[rustauth_core::plugin::PluginMigration],
309) -> Result<(), DbCliError> {
310    match provider {
311        "sqlite" | "sqlite3" => {
312            ensure_sqlite_database(database_url)?;
313            let adapter = SqliteAdapter::connect_with_schema(database_url, schema.clone()).await?;
314            adapter.run_migrations(schema).await?;
315            adapter.run_plugin_migrations(plugin_migrations).await?;
316        }
317        "postgres" | "postgresql" | "pg" => {
318            let adapter =
319                PostgresAdapter::connect_with_schema(database_url, schema.clone()).await?;
320            adapter.run_migrations(schema).await?;
321            adapter.run_plugin_migrations(plugin_migrations).await?;
322        }
323        "mysql" => {
324            let adapter = MySqlAdapter::connect_with_schema(database_url, schema.clone()).await?;
325            adapter.run_migrations(schema).await?;
326            adapter.run_plugin_migrations(plugin_migrations).await?;
327        }
328        other => return Err(DbCliError::UnsupportedProvider(other.to_owned())),
329    }
330    Ok(())
331}
332
333#[cfg(feature = "diesel")]
334async fn plan_with_diesel(
335    provider: &str,
336    database_url: &str,
337    schema: &DbSchema,
338) -> Result<SchemaMigrationPlan, DbCliError> {
339    match provider {
340        "postgres" | "postgresql" | "pg" => {
341            DieselPostgresAdapter::connect_with_schema(database_url, schema.clone())
342                .await?
343                .plan_migrations(schema)
344                .await
345                .map_err(Into::into)
346        }
347        "mysql" => DieselMysqlAdapter::connect_with_schema(database_url, schema.clone())
348            .await?
349            .plan_migrations(schema)
350            .await
351            .map_err(Into::into),
352        "sqlite" | "sqlite3" => Err(DbCliError::UnsupportedProvider(provider.to_owned())),
353        other => Err(DbCliError::UnsupportedProvider(other.to_owned())),
354    }
355}
356
357#[cfg(feature = "diesel")]
358async fn run_migrations_with_diesel(
359    provider: &str,
360    database_url: &str,
361    schema: &DbSchema,
362    plugin_migrations: &[rustauth_core::plugin::PluginMigration],
363) -> Result<(), DbCliError> {
364    match provider {
365        "postgres" | "postgresql" | "pg" => {
366            let adapter =
367                DieselPostgresAdapter::connect_with_schema(database_url, schema.clone()).await?;
368            adapter.run_migrations(schema).await?;
369            adapter.run_plugin_migrations(plugin_migrations).await?;
370        }
371        "mysql" => {
372            let adapter =
373                DieselMysqlAdapter::connect_with_schema(database_url, schema.clone()).await?;
374            adapter.run_migrations(schema).await?;
375            adapter.run_plugin_migrations(plugin_migrations).await?;
376        }
377        "sqlite" | "sqlite3" => return Err(DbCliError::UnsupportedProvider(provider.to_owned())),
378        other => return Err(DbCliError::UnsupportedProvider(other.to_owned())),
379    }
380    Ok(())
381}
382
383pub fn migration_sql(config: &CliConfig, planned: &PlannedMigration) -> Result<String, DbCliError> {
384    let dialect = dialect_from_provider(&planned.provider)
385        .ok_or_else(|| DbCliError::UnsupportedProvider(planned.provider.clone()))?;
386    let generated_at = OffsetDateTime::now_utc().format(&Rfc3339)?;
387    let schema_hash = schema_hash(&planned.schema)?;
388    let plan_hash = plan_hash(&planned.plan);
389    Ok(format!(
390        "-- RustAuth migration\n-- dialect: {}\n-- generated_at: {}\n-- schema_hash: {}\n-- plan_hash: {}\n-- config_base_path: {}\n\n{}",
391        dialect_name(dialect),
392        generated_at,
393        schema_hash,
394        plan_hash,
395        config.project.base_path,
396        planned.plan.compile()
397    ))
398}
399
400pub fn write_migration(
401    config: &CliConfig,
402    planned: &PlannedMigration,
403    output: Option<&Path>,
404    force: bool,
405) -> Result<PathBuf, DbCliError> {
406    write_migration_output(
407        config,
408        planned,
409        output
410            .map(|path| MigrationOutput::Directory(path.to_path_buf()))
411            .unwrap_or(MigrationOutput::Default),
412        force,
413    )
414}
415
416pub enum MigrationOutput {
417    Default,
418    Directory(PathBuf),
419    File(PathBuf),
420}
421
422pub fn write_migration_output(
423    config: &CliConfig,
424    planned: &PlannedMigration,
425    output: MigrationOutput,
426    force: bool,
427) -> Result<PathBuf, DbCliError> {
428    if planned.plan.is_empty() {
429        return Ok(PathBuf::new());
430    }
431    let (dir, explicit_file) = match output {
432        MigrationOutput::Default => (PathBuf::from(&config.database.migrations_dir), None),
433        MigrationOutput::Directory(dir) => (dir, None),
434        MigrationOutput::File(path) => (
435            path.parent()
436                .map(Path::to_path_buf)
437                .unwrap_or_else(|| PathBuf::from(".")),
438            Some(path),
439        ),
440    };
441    let hash = plan_hash(&planned.plan);
442    if !force {
443        if let Some(existing) = find_existing_plan_hash(&dir, &hash)? {
444            return Err(DbCliError::DuplicateMigration(
445                existing.display().to_string(),
446            ));
447        }
448    }
449    fs::create_dir_all(&dir).map_err(|source| DbCliError::CreateDir {
450        path: dir.clone(),
451        source,
452    })?;
453    let path = explicit_file.unwrap_or_else(|| {
454        dir.join(format!(
455            "{}_{}_{}.sql",
456            filename_timestamp(),
457            normalized_provider(&planned.provider),
458            hash
459        ))
460    });
461    if path.exists() && !force {
462        return Err(DbCliError::DuplicateMigration(path.display().to_string()));
463    }
464    let sql = migration_sql(config, planned)?;
465    fs::write(&path, sql).map_err(|source| DbCliError::Write {
466        path: path.clone(),
467        source,
468    })?;
469    Ok(path)
470}
471
472pub fn schema_hash(schema: &DbSchema) -> Result<String, DbCliError> {
473    let payload = serde_json::to_vec(schema)
474        .map_err(|error| RustAuthError::Adapter(format!("failed to serialize schema: {error}")))?;
475    Ok(short_hash(&payload))
476}
477
478pub fn plan_hash(plan: &SchemaMigrationPlan) -> String {
479    short_hash(plan.compile().as_bytes())
480}
481
482pub fn database_url(config: &CliConfig) -> Result<String, DbCliError> {
483    database_url_with_base(config, None)
484}
485
486pub fn database_url_with_base(
487    config: &CliConfig,
488    cwd: Option<&Path>,
489) -> Result<String, DbCliError> {
490    std::env::var(&config.database.url_env)
491        .map(|url| normalize_database_url(config.database.provider.as_deref(), &url, cwd))
492        .map_err(|_| DbCliError::MissingDatabaseUrl(config.database.url_env.clone()))
493}
494
495pub fn supports_sql_migrations(config: &CliConfig) -> bool {
496    let Some(adapter) = config.database_adapter() else {
497        return false;
498    };
499    if !is_cli_migration_adapter(adapter) {
500        return false;
501    }
502    match adapter {
503        "sqlx" if cfg!(feature = "sqlx") => config
504            .database
505            .provider
506            .as_deref()
507            .is_some_and(|provider| dialect_from_provider(provider).is_some()),
508        "tokio-postgres" if cfg!(feature = "tokio-postgres") => config
509            .database
510            .provider
511            .as_deref()
512            .is_some_and(is_postgres_provider),
513        "deadpool-postgres" if cfg!(feature = "deadpool-postgres") => config
514            .database
515            .provider
516            .as_deref()
517            .is_some_and(is_postgres_provider),
518        "diesel" if cfg!(feature = "diesel") => {
519            config.database.provider.as_deref().is_some_and(|provider| {
520                is_postgres_provider(provider) || is_mysql_provider(provider)
521            })
522        }
523        _ => false,
524    }
525}
526
527/// Adapters that are valid in the ecosystem but not driven by `rustauth db migrate`.
528///
529/// For these we print guidance and exit successfully (Better Auth parity for Prisma/Drizzle).
530pub fn unsupported_adapter_exits_successfully(adapter: &str) -> bool {
531    matches!(
532        adapter,
533        "prisma" | "drizzle" | "memory" | "mongodb" | "kysely"
534    )
535}
536
537pub fn unsupported_adapter_guidance(adapter: &str, command: &str) -> String {
538    match adapter {
539        "prisma" => format!(
540            "The {command} command applies RustAuth SQL migrations through the sqlx adapter. \
541             With Prisma configured, run `rustauth db generate` to write `.sql` files, then apply \
542             them with `prisma migrate` or `prisma db push`."
543        ),
544        "drizzle" => format!(
545            "The {command} command applies RustAuth SQL migrations through the sqlx adapter. \
546             With Drizzle configured, run `rustauth db generate` to write `.sql` files, then apply \
547             them with your Drizzle migration workflow."
548        ),
549        "kysely" => format!(
550            "The {command} command uses the sqlx adapter in rustauth.toml. \
551             Set `database.adapter = \"sqlx\"` and configure `database.provider`, or run \
552             `rustauth db generate` and apply the SQL with your existing Kysely tooling."
553        ),
554        "memory" => format!(
555            "The {command} command does not apply migrations for the in-memory adapter. \
556             Use `database.adapter = \"sqlx\"` with a real provider for CLI migrations, or \
557             `rustauth schema print` to inspect the target schema."
558        ),
559        "mongodb" => format!(
560            "The {command} command does not support MongoDB. \
561             Use a SQL provider with {}",
562            enabled_adapter_guidance()
563        ),
564        other => format!(
565            "Unsupported database adapter `{other}` for {command}. \
566             RustAuth CLI migrations require {}",
567            enabled_adapter_guidance()
568        ),
569    }
570}
571
572fn validate_cli_migration_adapter(config: &CliConfig) -> Result<(), DbCliError> {
573    let adapter = required_database_adapter(config)?;
574    if is_adapter_feature_disabled(adapter) {
575        return Err(DbCliError::AdapterFeatureDisabled(
576            adapter.to_owned(),
577            adapter_cargo_feature(adapter).to_owned(),
578        ));
579    }
580    if !is_cli_migration_adapter(adapter) {
581        return Err(DbCliError::UnsupportedAdapter(adapter.to_owned()));
582    }
583    Ok(())
584}
585
586fn required_database_adapter(config: &CliConfig) -> Result<&str, DbCliError> {
587    config.database_adapter().ok_or(DbCliError::MissingAdapter)
588}
589
590fn adapter_dispatch_error(adapter: &str) -> DbCliError {
591    if is_adapter_feature_disabled(adapter) {
592        DbCliError::AdapterFeatureDisabled(
593            adapter.to_owned(),
594            adapter_cargo_feature(adapter).to_owned(),
595        )
596    } else {
597        DbCliError::UnsupportedAdapter(adapter.to_owned())
598    }
599}
600
601fn adapter_cargo_feature(adapter: &str) -> &'static str {
602    match adapter {
603        "sqlx" => "sqlx",
604        "tokio-postgres" => "tokio-postgres",
605        "deadpool-postgres" => "deadpool-postgres",
606        "diesel" => "diesel",
607        _ => "unknown",
608    }
609}
610
611fn unsupported_adapter_support_suffix() -> String {
612    format!("CLI migrations support {}", enabled_adapter_guidance())
613}
614
615fn enabled_adapter_guidance() -> String {
616    let mut parts = Vec::new();
617    if cfg!(feature = "sqlx") {
618        parts.push("`database.adapter = \"sqlx\"` (sqlite, postgres, mysql)".to_owned());
619    }
620    if cfg!(feature = "tokio-postgres") {
621        parts.push("`database.adapter = \"tokio-postgres\"` (postgres only)".to_owned());
622    }
623    if cfg!(feature = "deadpool-postgres") {
624        parts.push("`database.adapter = \"deadpool-postgres\"` (postgres only)".to_owned());
625    }
626    if cfg!(feature = "diesel") {
627        parts.push("`database.adapter = \"diesel\"` (postgres, mysql)".to_owned());
628    }
629    if parts.is_empty() {
630        "no database migration adapters in this CLI build".to_owned()
631    } else {
632        parts.join(", ")
633    }
634}
635
636fn normalize_database_url(provider: Option<&str>, url: &str, cwd: Option<&Path>) -> String {
637    if !matches!(provider, Some("sqlite" | "sqlite3")) {
638        return url.to_owned();
639    }
640    let Some(cwd) = cwd else {
641        return url.to_owned();
642    };
643    let Some(path) = sqlite_path(url) else {
644        return url.to_owned();
645    };
646    if path.as_os_str().is_empty() || path.is_absolute() {
647        return url.to_owned();
648    }
649    format!("sqlite://{}", cwd.join(path).display())
650}
651
652fn short_hash(input: &[u8]) -> String {
653    let digest = Sha256::digest(input);
654    hex::encode(&digest[..8])
655}
656
657fn find_existing_plan_hash(dir: &Path, hash: &str) -> Result<Option<PathBuf>, DbCliError> {
658    if !dir.exists() {
659        return Ok(None);
660    }
661    for entry in fs::read_dir(dir).map_err(|source| DbCliError::Read {
662        path: dir.to_path_buf(),
663        source,
664    })? {
665        let entry = entry.map_err(|source| DbCliError::Read {
666            path: dir.to_path_buf(),
667            source,
668        })?;
669        let path = entry.path();
670        if path.extension().and_then(|extension| extension.to_str()) != Some("sql") {
671            continue;
672        }
673        let content = fs::read_to_string(&path).map_err(|source| DbCliError::Read {
674            path: path.clone(),
675            source,
676        })?;
677        if content.contains(&format!("plan_hash: {hash}")) {
678            return Ok(Some(path));
679        }
680    }
681    Ok(None)
682}
683
684fn filename_timestamp() -> String {
685    let now = OffsetDateTime::now_utc();
686    format!(
687        "{:04}{:02}{:02}{:02}{:02}{:02}",
688        now.year(),
689        u8::from(now.month()),
690        now.day(),
691        now.hour(),
692        now.minute(),
693        now.second()
694    )
695}
696
697fn normalized_provider(provider: &str) -> &str {
698    match provider {
699        "postgresql" | "pg" => "postgres",
700        "sqlite3" => "sqlite",
701        other => other,
702    }
703}
704
705fn ensure_sqlite_database(database_url: &str) -> Result<(), DbCliError> {
706    let Some(path) = sqlite_path(database_url) else {
707        return Ok(());
708    };
709    if path.as_os_str().is_empty() || path.exists() {
710        return Ok(());
711    }
712    if let Some(parent) = path.parent() {
713        fs::create_dir_all(parent).map_err(|source| DbCliError::CreateDir {
714            path: parent.to_path_buf(),
715            source,
716        })?;
717    }
718    fs::File::create(&path)
719        .map(|_| ())
720        .map_err(|source| DbCliError::Write { path, source })
721}
722
723fn sqlite_path(database_url: &str) -> Option<PathBuf> {
724    if database_url == "sqlite::memory:" || database_url == "sqlite://:memory:" {
725        return None;
726    }
727    database_url
728        .strip_prefix("sqlite://")
729        .or_else(|| database_url.strip_prefix("sqlite:"))
730        .map(PathBuf::from)
731}
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736
737    #[test]
738    #[cfg(feature = "diesel")]
739    fn diesel_is_known_cli_migration_adapter_when_feature_enabled() {
740        assert!(is_known_cli_migration_adapter("diesel"));
741        assert!(is_cli_migration_adapter("diesel"));
742        assert!(cli_migration_adapter_names().contains(&"diesel"));
743    }
744
745    #[test]
746    #[cfg(not(feature = "diesel"))]
747    fn diesel_is_known_but_disabled_without_feature() {
748        assert!(is_known_cli_migration_adapter("diesel"));
749        assert!(!is_cli_migration_adapter("diesel"));
750        assert!(!cli_migration_adapter_names().contains(&"diesel"));
751    }
752}