use std::fs;
use std::path::Path;
use anyhow::{Context, Result, bail};
use crate::{generator, naming, project, templates, timestamp};
const MIGRATIONS_DIR: &str = "database/migrations";
pub fn run(name: &str) -> Result<()> {
naming::validate_migration_name(name)?;
let root = project::find_root()?;
generate(&root.join(MIGRATIONS_DIR), name)
}
fn generate(dir: &Path, name: &str) -> Result<()> {
let registry = dir.join("mod.rs");
if !registry.is_file() {
bail!(
"{MIGRATIONS_DIR}/mod.rs not found; migrations need the layout created by `slave new`"
);
}
let existing = existing_migrations(dir)?;
if let Some((timestamp, _)) = existing.iter().find(|(_, other)| other == name) {
bail!("a migration named `{name}` already exists: {MIGRATIONS_DIR}/{timestamp}_{name}.rs");
}
let latest = existing
.iter()
.map(|(timestamp, _)| timestamp.as_str())
.max();
let timestamp = timestamp::utc_now_after(latest)?;
let stem = format!("{timestamp}_{name}");
let file_name = format!("{stem}.rs");
let struct_name = naming::to_pascal_case(name);
let (template, table) = match create_table_target(name) {
Some(table) => (templates::MIGRATION_CREATE_TABLE, table),
None => (templates::MIGRATION, ""),
};
let contents = templates::render(template, &[("struct", &struct_name), ("table", table)]);
generator::write_file(&dir.join(&file_name), &contents, false)?;
let module = format!("m{stem}");
let attach = [
format!("#[path = \"{file_name}\"]"),
format!("mod {module};"),
];
let mut registered = true;
for line in attach {
registered &=
generator::insert_before_marker(®istry, templates::MIGRATION_MODULES_MARKER, &line)?;
}
let entry = format!(" {module},");
registered &= generator::insert_before_marker(®istry, templates::MIGRATIONS_MARKER, &entry)?;
println!("Migration [{MIGRATIONS_DIR}/{file_name}] created successfully.");
if table.is_empty() {
println!(" note: fill in up(); down() returns None (irreversible) until you implement it");
}
if !registered {
println!(
" note: markers not found in {MIGRATIONS_DIR}/mod.rs; add `#[path = \"{file_name}\"] mod {module};` \
and list `{module}` in `migrations![...]` yourself"
);
}
Ok(())
}
fn create_table_target(name: &str) -> Option<&str> {
name.strip_prefix("create_")?
.strip_suffix("_table")
.filter(|table| !table.is_empty())
}
fn existing_migrations(dir: &Path) -> Result<Vec<(String, String)>> {
let entries = fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))?;
let mut migrations = Vec::new();
for entry in entries {
let file_name = entry?.file_name().to_string_lossy().into_owned();
let Some((timestamp, name)) = file_name
.strip_suffix(".rs")
.and_then(|stem| stem.split_once('_'))
else {
continue;
};
if timestamp.len() == 14 && timestamp.bytes().all(|b| b.is_ascii_digit()) {
migrations.push((timestamp.to_owned(), name.to_owned()));
}
}
Ok(migrations)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_create_table_migrations() {
assert_eq!(create_table_target("create_users_table"), Some("users"));
assert_eq!(
create_table_target("create_blog_posts_table"),
Some("blog_posts")
);
assert_eq!(create_table_target("create__table"), None);
assert_eq!(create_table_target("add_email_to_users"), None);
assert_eq!(create_table_target("create_users"), None);
}
}