use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::discovery::discover;
use crate::driver::{AppliedMigration, MigrationDriver};
use crate::error::{Error, Result};
#[derive(Debug)]
pub struct PendingMigration {
pub version: u32,
pub file: String,
pub why: Option<String>,
pub created: Option<String>,
pub author: Option<String>,
pub version_description: Option<String>,
}
#[derive(Debug)]
pub struct MigrationStatus {
pub applied: Vec<AppliedMigration>,
pub pending: Vec<PendingMigration>,
}
pub struct Migrator {
root: PathBuf,
}
impl Migrator {
pub fn from_root(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub async fn up(&self, driver: &dyn MigrationDriver) -> Result<()> {
let _lock = driver.acquire_lock().await?;
let (migrations, setup_files) = discover(&self.root)?;
for sf in &setup_files {
let sql = sf.read_sql()?;
driver.run_setup_sql(&sf.name, &sql).await?;
}
driver.ensure_tracking_table().await?;
let applied = driver.applied().await?;
for am in &applied {
match migrations
.iter()
.find(|m| m.version == am.version && m.file == am.file)
{
None => {
return Err(Error::AppliedButMissing {
version: am.version,
file: am.file.clone(),
});
}
Some(m) if m.checksum != am.checksum => {
return Err(Error::ChecksumDrift {
version: am.version,
file: am.file.clone(),
});
}
Some(_) => {}
}
}
let batch = applied.iter().map(|a| a.batch).max().unwrap_or(0) + 1;
let applied_keys: std::collections::HashSet<(u32, &str)> = applied
.iter()
.map(|a| (a.version, a.file.as_str()))
.collect();
for migration in &migrations {
if !applied_keys.contains(&(migration.version, migration.file.as_str())) {
let (up_sql, _) = migration.read_up();
driver.apply(migration, &up_sql, batch).await?;
}
}
Ok(())
}
pub async fn down(&self, driver: &dyn MigrationDriver, steps: usize) -> Result<()> {
if steps == 0 {
return Ok(());
}
let _lock = driver.acquire_lock().await?;
let (migrations, _setup_files) = discover(&self.root)?;
driver.ensure_tracking_table().await?;
let applied = driver.applied().await?;
for am in &applied {
if migrations
.iter()
.find(|m| m.version == am.version && m.file == am.file)
.is_none()
{
return Err(Error::AppliedButMissing {
version: am.version,
file: am.file.clone(),
});
}
}
let position: HashMap<(u32, &str), usize> = migrations
.iter()
.enumerate()
.map(|(i, m)| ((m.version, m.file.as_str()), i))
.collect();
let mut applied = applied;
applied.sort_by(|a, b| {
let pos_a = position
.get(&(a.version, a.file.as_str()))
.copied()
.unwrap_or(0);
let pos_b = position
.get(&(b.version, b.file.as_str()))
.copied()
.unwrap_or(0);
pos_b.cmp(&pos_a)
});
for am in applied.iter().take(steps) {
let migration = migrations
.iter()
.find(|m| m.version == am.version && m.file == am.file)
.ok_or_else(|| Error::MissingFile {
version: am.version,
file: am.file.clone(),
})?;
if migration.checksum != am.checksum {
return Err(Error::ChecksumDrift {
version: am.version,
file: am.file.clone(),
});
}
let down_sql = migration.read_down().ok_or_else(|| Error::MissingDown {
version: am.version,
file: am.file.clone(),
})?;
driver.revert(am, &down_sql).await?;
}
Ok(())
}
pub async fn status(&self, driver: &dyn MigrationDriver) -> Result<MigrationStatus> {
let _lock = driver.acquire_lock().await?;
let manifest_path = self.root.join("migration-order.yaml");
let yaml = std::fs::read_to_string(&manifest_path)?;
let manifest = crate::manifest::Manifest::from_yaml(&yaml)?;
let version_desc: HashMap<u32, Option<String>> = manifest
.versions
.into_iter()
.map(|mv| (mv.version, mv.description))
.collect();
let (migrations, _setup_files) = discover(&self.root)?;
driver.ensure_tracking_table().await?;
let applied = driver.applied().await?;
let applied_keys: std::collections::HashSet<(u32, &str)> = applied
.iter()
.map(|a| (a.version, a.file.as_str()))
.collect();
let pending: Vec<PendingMigration> = migrations
.iter()
.filter(|m| !applied_keys.contains(&(m.version, m.file.as_str())))
.map(|m| PendingMigration {
version: m.version,
file: m.file.clone(),
why: m.why.clone(),
created: m.created.clone(),
author: m.author.clone(),
version_description: version_desc.get(&m.version).and_then(|d| d.clone()),
})
.collect();
Ok(MigrationStatus { applied, pending })
}
pub fn scaffold(root: &Path) -> Result<()> {
std::fs::create_dir_all(root.join("00_setup"))?;
std::fs::create_dir_all(root.join("01_migrated").join("1"))?;
std::fs::create_dir_all(root.join("02_inprogress"))?;
let manifest_path = root.join("migration-order.yaml");
if !manifest_path.exists() {
std::fs::write(&manifest_path, MANIFEST_TEMPLATE)?;
}
let setup_path = root.join("00_setup").join("01_schema.sql");
if !setup_path.exists() {
std::fs::write(&setup_path, SETUP_TEMPLATE)?;
}
Ok(())
}
}
const MANIFEST_TEMPLATE: &str = r#"# migration-order.yaml
# Canonical, ordered record of every migration. The runner executes migrations
# in this exact order: versions ascending, then each migration top-to-bottom.
#
# RULES:
# - Every .sql file under a version folder MUST be listed here.
# - Every entry MUST resolve to a real file (01_migrated/<v>/<file> or 02_inprogress/<v>/<file>).
# - 'file' must be a bare filename (no path separators, no '..', must end in .sql).
# - (00_setup files are NOT listed here — they are the untracked idempotent bootstrap.)
#
# Version folders are named by a positive integer (1, 2, 3, ...).
# Stay in version 1 until you deliberately move to version 2.
# Sort versions NUMERICALLY (1, 2, ..., 10 — never lexically).
manifest_version: 1
versions:
- version: 1
description: "Initial schema"
migrations: []
# Add your migrations here, e.g.:
# - file: "20260101_01_init.sql"
# created: "2026-01-01"
# author: "you"
# why: "Create core tables"
"#;
const SETUP_TEMPLATE: &str = r#"-- 00_setup/01_schema.sql
-- Idempotent project bootstrap. Runs UNCONDITIONALLY before every up(), untracked.
-- Every statement MUST be idempotent: use IF NOT EXISTS, CREATE OR REPLACE, etc.
-- Replace 'myapp' with your application schema name.
CREATE SCHEMA IF NOT EXISTS myapp;
-- Example: enable pgcrypto for gen_random_uuid().
-- CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Example: a shared updated_at trigger function (CREATE OR REPLACE is idempotent).
-- CREATE OR REPLACE FUNCTION fn_update_timestamp()
-- RETURNS TRIGGER LANGUAGE plpgsql AS $$
-- BEGIN
-- NEW.updated_at = now();
-- RETURN NEW;
-- END;
-- $$;
"#;