use std::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
const OLD_MIGRATIONS_DIR: &str = "src/migrations";
const MIGRATIONS_DIR: &str = "db/migrations";
pub(crate) fn migrations_dir() -> (&'static str, bool) {
if Path::new(OLD_MIGRATIONS_DIR).join("mod.rs").exists() {
(OLD_MIGRATIONS_DIR, true)
} else {
(MIGRATIONS_DIR, false)
}
}
pub fn migration(name: &str) -> Result<(), String> {
migration_with(name, &migration_source(name))
}
pub fn named_migration(name: &str, database: &str) -> Result<(), String> {
if database.is_empty()
|| !database
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
{
return Err(format!(
"invalid database name `{database}`: use the name from [databases.<name>], e.g. analytics"
));
}
let folder = format!("{database}_migrations");
let old_dir = format!("src/{folder}");
let old = Path::new(&old_dir).join("mod.rs").exists();
let dir = if old {
old_dir
} else {
format!("db/{database}/migrations")
};
let new_folder = !Path::new(&dir).exists();
write_migration(Path::new(&dir), name, &migration_source(name))?;
if old {
write_index_titled(
Path::new(&dir),
&format!(
"//! The {database} database's migrations, oldest first. `glacier db\n\
//! migrate` runs them after the app's own."
),
)?;
println!(" update {dir}/mod.rs");
}
if new_folder && !crate::scaffold::discovers() {
let lib_path = "src/lib.rs";
let lib = fs::read_to_string(lib_path).map_err(|e| format!("reading {lib_path}: {e}"))?;
let mut updated = lib.clone();
let declare = if old {
format!("pub mod {folder};\n")
} else {
format!("glacier::migrations!({database});\n")
};
if !updated.contains(&declare) {
let anchor = ["glacier::migrations!();\n", "pub mod migrations;\n"]
.iter()
.find_map(|anchor| updated.find(anchor).map(|at| at + anchor.len()));
updated = match anchor {
Some(at) => format!("{}{declare}{}", &updated[..at], &updated[at..]),
None => format!("{declare}{updated}"),
};
}
let register = format!(".named_migrations(\"{database}\", {folder}::all())");
if !updated.contains(®ister) {
let anchor = ".migrations(migrations::all())";
match updated.find(anchor) {
Some(at) => {
let line_start = updated[..at].rfind('\n').map_or(0, |n| n + 1);
let indent = updated[line_start..at].to_owned();
let end = at + anchor.len();
updated.insert_str(end, &format!("\n{indent}{register}"));
}
None => println!("\nRegister them on your App in {lib_path}:\n\n {register}"),
}
}
if updated != lib {
fs::write(lib_path, updated).map_err(|e| format!("writing {lib_path}: {e}"))?;
println!(" update {lib_path}");
}
}
Ok(())
}
pub fn migration_with(name: &str, source: &str) -> Result<(), String> {
if !Path::new("Cargo.toml").exists() {
return Err("no Cargo.toml here; run this from your app's directory".into());
}
if name.is_empty()
|| !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
{
return Err(format!(
"invalid migration name `{name}`: use snake_case, e.g. create_posts"
));
}
let (dir, listed) = migrations_dir();
write_migration(Path::new(dir), name, source)?;
if listed {
write_index(Path::new(dir))?;
println!(" update {dir}/mod.rs");
}
Ok(())
}
fn write_migration(dir: &Path, name: &str, source: &str) -> Result<(), String> {
fs::create_dir_all(dir).map_err(|e| format!("creating {}: {e}", dir.display()))?;
let module = format!(
"m{}_{name}",
next_version(&existing_versions(dir)?, now_secs())
);
let extension = if source.lines().any(|l| l.trim() == "migration do") {
"glacier"
} else {
"rs"
};
let path = dir.join(format!("{module}.{extension}"));
fs::write(&path, source).map_err(|e| format!("writing {}: {e}", path.display()))?;
println!(" create {}", path.display());
Ok(())
}
fn migration_source(name: &str) -> String {
if let Some(table) = name.strip_prefix("create_") {
return format!(
"migration do\n create_table :{table} do\n # string :title, null: false\n timestamps\n end\nend\n"
);
}
if let Some((column, table)) = name.strip_prefix("add_").and_then(|r| r.split_once("_to_")) {
return format!(
"migration do\n change_table :{table} do\n string :{column}\n end\nend\n"
);
}
if let Some((column, table)) = name
.strip_prefix("remove_")
.and_then(|r| r.split_once("_from_"))
{
return format!(
"# Removing a column can't be undone on its own: `down` says what it was.\n\
migration do\n up do\n change_table :{table} do\n remove :{column}\n end\n end\n\n \
down do\n change_table :{table} do\n string :{column}\n end\n end\nend\n"
);
}
"# Steps go here: create_table, change_table, add_index, rename_table; each\n\
# is undone by itself on rollback. A step that can't be (drop_table,\n\
# execute, remove) goes in `up do … end`, with `down do … end` beside it.\n\
migration do\nend\n"
.to_owned()
}
fn next_version(existing: &[String], now: i64) -> String {
let now = utc_timestamp(now);
match existing.iter().max() {
Some(latest) if latest.as_str() >= now.as_str() => {
utc_timestamp(parse_timestamp(latest).map_or(0, |secs| secs + 1))
}
_ => now,
}
}
fn existing_versions(dir: &Path) -> Result<Vec<String>, String> {
Ok(migration_modules(dir)?
.iter()
.map(|m| m[1..].chars().take_while(char::is_ascii_digit).collect())
.collect())
}
fn parse_timestamp(s: &str) -> Option<i64> {
let n = |range: std::ops::Range<usize>| s.get(range)?.parse::<i64>().ok();
let (year, month, day) = (n(0..4)?, n(4..6)?, n(6..8)?);
let (hour, minute, second) = (n(8..10)?, n(10..12)?, n(12..14)?);
let y = if month <= 2 { year - 1 } else { year };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let mp = (month + 9) % 12;
let doy = (153 * mp + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
Some(days * 86_400 + hour * 3600 + minute * 60 + second)
}
fn migration_modules(dir: &Path) -> Result<Vec<String>, String> {
let mut modules: Vec<String> = fs::read_dir(dir)
.map_err(|e| format!("reading {}: {e}", dir.display()))?
.filter_map(|entry| {
let name = entry.ok()?.file_name().into_string().ok()?;
let module = name
.strip_suffix(".rs")
.or_else(|| name.strip_suffix(".glacier"))?;
let is_migration = module
.strip_prefix('m')
.is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit()));
is_migration.then(|| module.to_owned())
})
.collect();
modules.sort();
Ok(modules)
}
pub fn write_index(dir: &Path) -> Result<(), String> {
write_index_titled(
dir,
"//! The app's migrations, oldest first. `glacier generate migration`\n\
//! keeps this list up to date.",
)
}
fn write_index_titled(dir: &Path, title: &str) -> Result<(), String> {
let modules = migration_modules(dir)?;
let mut source = format!("{title}\n\nglacier::migrations! {{\n");
for module in &modules {
source.push_str(&format!(" {module},\n"));
}
source.push_str("}\n");
fs::write(dir.join("mod.rs"), source).map_err(|e| format!("writing mod.rs: {e}"))
}
fn now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64)
}
fn utc_timestamp(secs: i64) -> String {
let days = secs.div_euclid(86_400);
let rem = secs.rem_euclid(86_400);
let (hour, minute, second) = (rem / 3600, rem % 3600 / 60, rem % 60);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
format!("{year:04}{month:02}{day:02}{hour:02}{minute:02}{second:02}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_utc_timestamps() {
assert_eq!(utc_timestamp(0), "19700101000000");
assert_eq!(utc_timestamp(951_782_400), "20000229000000");
assert_eq!(utc_timestamp(1_759_449_599), "20251002235959");
}
#[test]
fn versions_never_collide() {
let now = 951_782_400; assert_eq!(next_version(&[], now), "20000229000000");
assert_eq!(
next_version(&["19991231235959".into()], now),
"20000229000000"
);
assert_eq!(
next_version(&["20000229000000".into()], now),
"20000229000001"
);
assert_eq!(
next_version(&["20000228235959".into(), "20000229235959".into()], now),
"20000301000000"
);
assert_eq!(parse_timestamp("20000229000000"), Some(now));
}
#[test]
fn migration_templates_compile() {
let rust = |name: &str| {
glacier_dialect::transpile(&migration_source(name), &format!("m1_{name}.glacier"))
.unwrap_or_else(|e| panic!("{name}: {e}"))
.rust
};
let create = rust("create_posts");
assert!(create.contains(r#"s.create_table("posts""#), "{create}");
assert!(create.contains(r#"s.drop_table("posts")"#), "{create}");
let add = rust("add_slug_to_posts");
assert!(add.contains(r#"t.string("slug");"#), "{add}");
assert!(add.contains(r#"t.drop_column("slug");"#), "{add}");
let remove = rust("remove_slug_from_posts");
assert!(remove.contains(r#"t.drop_column("slug");"#), "{remove}");
assert!(rust("backfill_things").contains("pub fn down(s: &mut Schema)"));
}
}