use std::path::Path;
use autumn_schema_core::Backend;
use diesel_migrations::FileBasedMigrations;
use autumn_web::migrate::{DEFAULT_LOCK_WAIT_TIMEOUT, MigrationResult};
use super::snapshot::{SNAPSHOT_DEFAULT_PATH, SnapshotError, load_snapshot};
pub fn run_migrate(profile: Option<&str>) -> Result<(), String> {
let project_root = std::env::current_dir()
.map_err(|e| format!("failed to resolve the current directory: {e}"))?;
migrate_at(&project_root, profile)
}
fn migrate_at(project_root: &Path, profile: Option<&str>) -> Result<(), String> {
crate::generate::ensure_project_root(project_root)
.map_err(|_| crate::generate::GenerateError::NotInProject.to_string())?;
let url = crate::migrate::resolve_primary_url(profile);
let backend = super::backend_for_url(project_root, profile, url.as_deref());
let snapshot_path = project_root.join(SNAPSHOT_DEFAULT_PATH);
if !enforce_provider_lock(&snapshot_path, backend)? {
eprintln!(
"note: no schema snapshot at {} — applying migrations without a provider-lock check; \
run `autumn schema snapshot` to establish the baseline.",
snapshot_path.display()
);
}
let url = url.ok_or_else(|| {
"no database URL configured — set AUTUMN_DATABASE__URL or DATABASE_URL, or add a \
[database] primary_url to autumn.toml"
.to_string()
})?;
let migrations_dir = project_root.join("migrations");
match classify_migrations_dir(&migrations_dir) {
MigrationsDir::Absent | MigrationsDir::Empty => {
println!("No migrations to apply.");
return Ok(());
}
MigrationsDir::Unreadable(e) => {
return Err(format!(
"failed to read migrations directory {}: {e}",
migrations_dir.display()
));
}
MigrationsDir::HasMigrations => {}
}
let result = apply_pending(backend, &url, &migrations_dir)?;
report_applied(&result);
Ok(())
}
fn enforce_provider_lock(snapshot_path: &Path, backend: Backend) -> Result<bool, String> {
match load_snapshot(snapshot_path) {
Ok(snapshot) => {
snapshot
.ensure_backend_matches(backend)
.map_err(|e| e.to_string())?;
Ok(true)
}
Err(SnapshotError::Io { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => {
Ok(false)
}
Err(other) => Err(other.to_string()),
}
}
fn apply_pending(
backend: Backend,
url: &str,
migrations_dir: &Path,
) -> Result<MigrationResult, String> {
match backend {
Backend::Postgres => apply_pending_pg(url, migrations_dir),
Backend::Sqlite => apply_pending_sqlite(url, migrations_dir),
}
}
fn apply_pending_pg(url: &str, migrations_dir: &Path) -> Result<MigrationResult, String> {
let migrations = file_based_migrations(migrations_dir)?;
autumn_web::migrate::run_pending_locked(url, migrations, Some(DEFAULT_LOCK_WAIT_TIMEOUT))
.map_err(|e| e.to_string())
}
#[cfg(feature = "sqlite")]
fn apply_pending_sqlite(url: &str, migrations_dir: &Path) -> Result<MigrationResult, String> {
let migrations = file_based_migrations(migrations_dir)?;
autumn_web::migrate::run_pending_sqlite(url, migrations).map_err(|e| e.to_string())
}
#[cfg(not(feature = "sqlite"))]
fn apply_pending_sqlite(_url: &str, _migrations_dir: &Path) -> Result<MigrationResult, String> {
Err(
"`autumn schema migrate` detected a SQLite backend, but this CLI build targets Postgres \
only. Rebuild with `--features sqlite` to apply SQLite migrations (the sqlite \
backend-flip must never be co-built with the default Postgres backend)."
.to_string(),
)
}
fn file_based_migrations(migrations_dir: &Path) -> Result<FileBasedMigrations, String> {
FileBasedMigrations::from_path(migrations_dir).map_err(|e| {
format!(
"failed to read migrations directory {}: {e}",
migrations_dir.display()
)
})
}
#[derive(Debug)]
enum MigrationsDir {
Absent,
Empty,
HasMigrations,
Unreadable(std::io::Error),
}
fn classify_migrations_dir(migrations_dir: &Path) -> MigrationsDir {
match migrations_dir.try_exists() {
Ok(false) => return MigrationsDir::Absent,
Ok(true) => {}
Err(e) => return MigrationsDir::Unreadable(e),
}
let entries = match std::fs::read_dir(migrations_dir) {
Ok(entries) => entries,
Err(e) => return MigrationsDir::Unreadable(e),
};
match entries.collect::<Result<Vec<_>, _>>() {
Ok(entries) => {
if entries.iter().any(|e| e.path().is_dir()) {
MigrationsDir::HasMigrations
} else {
MigrationsDir::Empty
}
}
Err(e) => MigrationsDir::Unreadable(e),
}
}
fn report_applied(result: &MigrationResult) {
if result.applied.is_empty() {
println!("Database already up to date.");
return;
}
for name in &result.applied {
println!(" applied {name}");
}
println!("Applied {} migration(s).", result.applied.len());
}
#[cfg(test)]
#[allow(clippy::needless_raw_string_hashes)]
mod tests {
use super::*;
use super::super::snapshot::{SchemaSnapshot, write_snapshot};
#[test]
fn classify_absent_path_is_a_no_op() {
let root = tempfile::tempdir().expect("tempdir");
assert!(matches!(
classify_migrations_dir(&root.path().join("migrations")),
MigrationsDir::Absent
));
}
#[test]
fn classify_readable_empty_dir_is_a_no_op() {
let root = tempfile::tempdir().expect("tempdir");
let migrations = root.path().join("migrations");
std::fs::create_dir_all(&migrations).expect("mkdir");
assert!(matches!(
classify_migrations_dir(&migrations),
MigrationsDir::Empty
));
std::fs::write(migrations.join("README.md"), "x").expect("write");
assert!(matches!(
classify_migrations_dir(&migrations),
MigrationsDir::Empty
));
}
#[test]
fn classify_dir_with_migration_subdir_proceeds() {
let root = tempfile::tempdir().expect("tempdir");
let migrations = root.path().join("migrations");
std::fs::create_dir_all(migrations.join("20260101000000_init")).expect("mkdir");
assert!(matches!(
classify_migrations_dir(&migrations),
MigrationsDir::HasMigrations
));
}
#[test]
fn classify_regular_file_is_unreadable_error() {
let root = tempfile::tempdir().expect("tempdir");
let migrations = root.path().join("migrations");
std::fs::write(&migrations, "not a directory").expect("write");
assert!(matches!(
classify_migrations_dir(&migrations),
MigrationsDir::Unreadable(_)
));
}
#[cfg(unix)]
#[test]
fn classify_unreadable_dir_is_an_error() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().expect("tempdir");
let migrations = root.path().join("migrations");
std::fs::create_dir_all(&migrations).expect("mkdir");
std::fs::set_permissions(&migrations, std::fs::Permissions::from_mode(0o000))
.expect("chmod");
let can_still_read = std::fs::read_dir(&migrations).is_ok();
let classified = classify_migrations_dir(&migrations);
std::fs::set_permissions(&migrations, std::fs::Permissions::from_mode(0o755))
.expect("restore chmod");
if can_still_read {
return;
}
assert!(matches!(classified, MigrationsDir::Unreadable(_)));
}
#[test]
fn provider_lock_missing_snapshot_is_not_an_error() {
let root = tempfile::tempdir().expect("tempdir");
let path = root.path().join(SNAPSHOT_DEFAULT_PATH);
assert_eq!(enforce_provider_lock(&path, Backend::Postgres), Ok(false));
}
#[test]
fn provider_lock_mismatch_is_refused() {
let root = tempfile::tempdir().expect("tempdir");
let path = root.path().join(SNAPSHOT_DEFAULT_PATH);
let snap = SchemaSnapshot::new(Backend::Sqlite, Vec::new());
write_snapshot(&path, &snap).expect("write snapshot");
let err = enforce_provider_lock(&path, Backend::Postgres).unwrap_err();
assert!(
err.to_lowercase().contains("backend") && err.contains("does not match"),
"provider-lock refusal: {err}"
);
}
#[test]
fn provider_lock_match_is_ok() {
let root = tempfile::tempdir().expect("tempdir");
let path = root.path().join(SNAPSHOT_DEFAULT_PATH);
let snap = SchemaSnapshot::new(Backend::Postgres, Vec::new());
write_snapshot(&path, &snap).expect("write snapshot");
assert_eq!(enforce_provider_lock(&path, Backend::Postgres), Ok(true));
}
#[test]
fn migrate_outside_project_is_friendly_error() {
let root = tempfile::tempdir().expect("tempdir");
let err = migrate_at(root.path(), None).unwrap_err();
assert!(
err.contains("Autumn project"),
"friendly not-in-project error: {err}"
);
}
#[cfg(not(feature = "sqlite"))]
#[test]
fn sqlite_apply_seam_names_the_feature_in_default_build() {
let root = tempfile::tempdir().expect("tempdir");
let err = apply_pending_sqlite("sqlite:///tmp/x.db", &root.path().join("migrations"))
.unwrap_err();
assert!(
err.contains("--features sqlite") && err.contains("SQLite"),
"seam error names the feature: {err}"
);
}
}