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
527pub 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}