use std::path::Path;
use super::dsl::parse_fields;
use super::emit::Plan;
use super::naming::pascal_to_snake;
use super::schema_edit::{
MigrationShape, add_columns_down_sql, add_columns_up_sql, detect_migration_shape,
remove_columns_down_sql, remove_columns_up_sql,
};
use super::{Flags, GenerateError, ensure_project_root, timestamp_now};
pub fn plan_migration(
project_root: &Path,
name: &str,
field_tokens: &[String],
timestamp: &str,
) -> Result<Plan, GenerateError> {
ensure_project_root(project_root)?;
super::model::validate_resource_name(name)?;
let fields = parse_fields(field_tokens)?;
let dir_name = format!("{timestamp}_{}", snake_or_pascal_to_snake(name));
let migration_dir = project_root.join("migrations").join(&dir_name);
let shape = detect_migration_shape(&pascalish(name));
let (up, down) = match shape {
MigrationShape::AddColumns { ref table } if !fields.is_empty() => (
add_columns_up_sql(table, &fields),
add_columns_down_sql(table, &fields),
),
MigrationShape::RemoveColumns { ref table } if !fields.is_empty() => (
remove_columns_up_sql(table, &fields),
remove_columns_down_sql(table, &fields),
),
_ => (String::new(), String::new()),
};
let mut plan = Plan::new(project_root);
plan.create(migration_dir.join("up.sql"), up);
plan.create(migration_dir.join("down.sql"), down);
Ok(plan)
}
fn snake_or_pascal_to_snake(name: &str) -> String {
if name.contains('_') || !name.chars().any(char::is_uppercase) {
name.to_ascii_lowercase()
} else {
pascal_to_snake(name)
}
}
fn pascalish(name: &str) -> String {
if name.contains('_') {
super::naming::snake_to_pascal(name)
} else {
name.to_owned()
}
}
pub fn run(name: &str, field_tokens: &[String], flags: Flags) {
let cwd = match std::env::current_dir() {
Ok(d) => d,
Err(e) => {
eprintln!("Error: cannot determine current directory: {e}");
std::process::exit(1);
}
};
let timestamp = timestamp_now();
match plan_migration(&cwd, name, field_tokens, ×tamp).and_then(|p| p.execute(flags)) {
Ok(()) => {}
Err(e) => {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn project() -> TempDir {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("Cargo.toml"), "[package]\nname=\"x\"\n").unwrap();
tmp
}
#[test]
fn empty_migration_when_no_keyword_match() {
let tmp = project();
let plan = plan_migration(tmp.path(), "BackfillSomething", &[], "20260427000000").unwrap();
plan.execute(Flags::default()).unwrap();
let dir = tmp
.path()
.join("migrations/20260427000000_backfill_something");
let up = fs::read_to_string(dir.join("up.sql")).unwrap();
let down = fs::read_to_string(dir.join("down.sql")).unwrap();
assert!(up.is_empty());
assert!(down.is_empty());
}
#[test]
fn add_columns_migration_emits_alter() {
let tmp = project();
let plan = plan_migration(
tmp.path(),
"AddTitleToPosts",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_add_title_to_posts/up.sql"),
)
.unwrap();
let down = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_add_title_to_posts/down.sql"),
)
.unwrap();
assert!(up.contains("ALTER TABLE posts ADD COLUMN title TEXT NOT NULL"));
assert!(down.contains("ALTER TABLE posts DROP COLUMN title"));
}
#[test]
fn remove_columns_migration_emits_drop() {
let tmp = project();
let plan = plan_migration(
tmp.path(),
"RemoveBodyFromPosts",
&["body:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_remove_body_from_posts/up.sql"),
)
.unwrap();
assert!(up.contains("ALTER TABLE posts DROP COLUMN body"));
}
#[test]
fn add_pattern_with_no_fields_is_empty() {
let tmp = project();
let plan = plan_migration(tmp.path(), "AddTitleToPosts", &[], "20260427000000").unwrap();
plan.execute(Flags::default()).unwrap();
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_add_title_to_posts/up.sql"),
)
.unwrap();
assert!(up.is_empty());
}
#[test]
fn snake_case_name_is_accepted() {
let tmp = project();
let plan = plan_migration(
tmp.path(),
"add_title_to_posts",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_add_title_to_posts/up.sql"),
)
.unwrap();
assert!(up.contains("ALTER TABLE posts ADD COLUMN title TEXT NOT NULL"));
}
}