use std::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
const MIGRATIONS_DIR: &str = "src/migrations";
pub fn migration(name: &str) -> Result<(), String> {
migration_with(name, &migration_source(name))
}
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 = Path::new(MIGRATIONS_DIR);
fs::create_dir_all(dir).map_err(|e| format!("creating {MIGRATIONS_DIR}: {e}"))?;
let module = format!(
"m{}_{name}",
next_version(&existing_versions(dir)?, now_secs())
);
let path = dir.join(format!("{module}.rs"));
fs::write(&path, source).map_err(|e| format!("writing {}: {e}", path.display()))?;
println!(" create {}", path.display());
write_index(dir)?;
println!(" update {MIGRATIONS_DIR}/mod.rs");
Ok(())
}
fn migration_source(name: &str) -> String {
let header = "use glacier::db::migration::*;\n\n";
if let Some(table) = name.strip_prefix("create_") {
return format!(
r#"{header}pub fn up(s: &mut Schema) {{
s.create_table("{table}", |t| {{
// t.string("title").not_null();
t.timestamps();
}});
}}
pub fn down(s: &mut Schema) {{
s.drop_table("{table}");
}}
"#
);
}
if let Some((column, table)) = name.strip_prefix("add_").and_then(|r| r.split_once("_to_")) {
return format!(
r#"{header}pub fn up(s: &mut Schema) {{
s.alter_table("{table}", |t| {{
t.string("{column}");
}});
}}
pub fn down(s: &mut Schema) {{
s.alter_table("{table}", |t| t.drop_column("{column}"));
}}
"#
);
}
if let Some((column, table)) = name
.strip_prefix("remove_")
.and_then(|r| r.split_once("_from_"))
{
return format!(
r#"{header}pub fn up(s: &mut Schema) {{
s.alter_table("{table}", |t| t.drop_column("{column}"));
}}
pub fn down(s: &mut Schema) {{
s.alter_table("{table}", |t| {{
t.string("{column}");
}});
}}
"#
);
}
format!(
r#"{header}// Rename `_s` to `s` once you add steps, e.g.
// s.alter_table("posts", |t| {{ t.string("subtitle"); }});
pub fn up(_s: &mut Schema) {{}}
pub fn down(_s: &mut Schema) {{}}
"#
)
}
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")?;
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> {
let modules = migration_modules(dir)?;
let mut source = String::from(
"//! The app's migrations, oldest first. `glacier generate migration`\n\
//! keeps this list up to date.\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 create_migrations_get_a_table() {
let source = migration_source("create_posts");
assert!(source.contains(r#"s.create_table("posts""#));
assert!(source.contains(r#"s.drop_table("posts")"#));
let add = migration_source("add_slug_to_posts");
assert!(add.contains(
r#"s.alter_table("posts", |t| {
t.string("slug");"#
));
assert!(add.contains(r#"t.drop_column("slug")"#));
let remove = migration_source("remove_slug_from_posts");
assert!(remove.contains(r#"s.alter_table("posts", |t| t.drop_column("slug"));"#));
assert!(migration_source("backfill_things").contains("pub fn up(_s: &mut Schema) {}"));
}
}