use std::{fs, path::Path};
pub fn run_migration(
name: &str,
base: &Path,
content: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let migrations_dir = base.join("infrastructure/migrations");
fs::create_dir_all(&migrations_dir)?;
let normalised = name.replace(' ', "_").to_lowercase();
let version = next_version(&migrations_dir, utc_timestamp_now())?;
let path = migrations_dir.join(format!("{version}_{normalised}.sql"));
let header = format!(
"-- Puerto migration: {normalised}\n-- Run `make sqlx/prepare` after editing this file.\n\n"
);
let body = content.unwrap_or("-- Add migration script here\n");
fs::write(&path, format!("{header}{body}"))?;
println!("✓ Migration '{normalised}' created in infrastructure/migrations/");
if content.is_some() {
println!(" Schema pre-filled — run: make sqlx/migrate");
} else {
println!(" Edit the file, then run: make sqlx/migrate");
}
Ok(())
}
pub fn run_generate_migration(name: &str, base: &Path) -> Result<(), Box<dyn std::error::Error>> {
let config = crate::puerto_toml::read(base)?;
if !config.project.db {
return Err(
"this project has no database — migrations need a project created with \
`puerto new --db`"
.into(),
);
}
run_migration(name, base, None)
}
fn next_version(migrations_dir: &Path, now: u64) -> Result<u64, std::io::Error> {
let newest = fs::read_dir(migrations_dir)?
.flatten()
.filter_map(|e| {
let name = e.file_name();
let prefix = name.to_string_lossy().split('_').next()?.to_string();
prefix.parse::<u64>().ok()
})
.max();
Ok(match newest {
Some(v) if v >= now => bump_timestamp(v),
_ => now,
})
}
fn bump_timestamp(v: u64) -> u64 {
let (date, time) = (v / 1_000_000, v % 1_000_000);
let secs = (time / 10_000) * 3600 + (time / 100 % 100) * 60 + time % 100 + 1;
let (y, m, d) = (date / 10_000, date / 100 % 100, date % 100);
let days = days_from_civil(y as i64, m as u32, d as u32) + (secs / 86_400) as i64;
let secs = secs % 86_400;
let (y, m, d) = civil_from_days(days);
format_timestamp(y, m, d, secs)
}
fn utc_timestamp_now() -> u64 {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
timestamp_from_unix_secs(secs)
}
fn timestamp_from_unix_secs(secs: u64) -> u64 {
let (y, m, d) = civil_from_days((secs / 86_400) as i64);
format_timestamp(y, m, d, secs % 86_400)
}
fn format_timestamp(y: i64, m: u32, d: u32, secs_of_day: u64) -> u64 {
let (h, min, s) = (secs_of_day / 3600, secs_of_day / 60 % 60, secs_of_day % 60);
format!("{y:04}{m:02}{d:02}{h:02}{min:02}{s:02}")
.parse()
.unwrap_or(0)
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 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 d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let y = yoe + era * 400 + i64::from(m <= 2);
(y, m, d)
}
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y.rem_euclid(400);
let m = i64::from(m);
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + i64::from(d) - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bump_carries_across_minute_hour_and_day() {
assert_eq!(bump_timestamp(20261010065431), 20261010065432);
assert_eq!(bump_timestamp(20261010065459), 20261010065500);
assert_eq!(bump_timestamp(20261231235959), 20270101000000);
assert_eq!(bump_timestamp(20240228235959), 20240229000000);
}
#[test]
fn civil_round_trips() {
for days in [-1, 0, 1, 19_000, 20_736] {
let (y, m, d) = civil_from_days(days);
assert_eq!(days_from_civil(y, m, d), days);
}
assert_eq!(civil_from_days(0), (1970, 1, 1));
}
#[test]
fn should_use_the_current_time_when_existing_migrations_are_older() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("20200101000000_old.sql"), "").unwrap();
let version = next_version(dir.path(), 20261010065431).unwrap();
assert_eq!(version, 20261010065431);
}
#[test]
fn should_bump_past_a_migration_created_in_the_same_second() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("20261010065431_first.sql"), "").unwrap();
let version = next_version(dir.path(), 20261010065431).unwrap();
assert_eq!(version, 20261010065432);
}
#[test]
fn should_map_known_days_to_their_calendar_dates() {
assert_eq!(civil_from_days(20_736), (2026, 10, 10));
assert_eq!(civil_from_days(11_016), (2000, 2, 29));
assert_eq!(days_from_civil(2026, 10, 10), 20_736);
}
#[test]
fn should_format_now_as_a_plausible_utc_timestamp() {
let now = utc_timestamp_now();
let (date, time) = (now / 1_000_000, now % 1_000_000);
assert!((20260101..21000101).contains(&date), "{now}");
assert!(
time / 10_000 < 24 && time / 100 % 100 < 60 && time % 100 < 60,
"{now}"
);
}
#[test]
fn should_convert_unix_seconds_to_a_utc_timestamp() {
assert_eq!(timestamp_from_unix_secs(0), 19700101000000);
assert_eq!(timestamp_from_unix_secs(1_791_615_271), 20261010065431);
}
#[test]
fn should_map_era_boundaries_to_their_calendar_dates() {
for (days, date) in [
(-25_508, (1900, 3, 1)),
(-1, (1969, 12, 31)),
(11_017, (2000, 3, 1)),
(47_540, (2100, 2, 28)),
(47_541, (2100, 3, 1)),
(157_113, (2400, 2, 29)),
] {
assert_eq!(civil_from_days(days), date, "day {days}");
assert_eq!(days_from_civil(date.0, date.1, date.2), days, "{date:?}");
}
}
}