use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
pub fn migration_prefix_now() -> Result<String> {
Ok(prefix(now_secs()?))
}
pub fn next_migration_prefix(dir: &Path) -> Result<String> {
let latest = std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.filter_map(|entry| parse_prefix(&entry.file_name().to_string_lossy()))
.max();
let now = now_secs()?;
Ok(prefix(latest.map_or(now, |latest| now.max(latest + 1))))
}
fn prefix(unix_secs: u64) -> String {
let stamp = format_utc(unix_secs);
format!("{}_{}", &stamp[..8], &stamp[8..])
}
fn parse_prefix(name: &str) -> Option<u64> {
let (date, rest) = (name.get(..8)?, name.get(8..15)?);
let time = rest.strip_prefix('_')?;
if !date.bytes().chain(time.bytes()).all(|b| b.is_ascii_digit()) {
return None;
}
let num = |s: &str| s.parse::<u64>().ok();
let days = days_from_civil(num(&date[..4])?, num(&date[4..6])?, num(&date[6..])?)?;
Some(days * 86_400 + num(&time[..2])? * 3600 + num(&time[2..4])? * 60 + num(&time[4..])?)
}
fn days_from_civil(year: u64, month: u64, day: u64) -> Option<u64> {
if !(1..=12).contains(&month) || !(1..=31).contains(&day) || year < 1970 {
return None;
}
let year = if month <= 2 { year - 1 } else { year };
let era = year / 400;
let yoe = year - era * 400;
let mp = if month > 2 { month - 3 } else { month + 9 };
let doy = (153 * mp + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
(era * 146_097 + doe).checked_sub(719_468)
}
fn now_secs() -> Result<u64> {
Ok(SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("system clock is set before 1970")?
.as_secs())
}
fn format_utc(unix_secs: u64) -> String {
let (days, rem) = (unix_secs / 86_400, unix_secs % 86_400);
let (year, month, day) = civil_from_days(days);
format!(
"{year:04}{month:02}{day:02}{:02}{:02}{:02}",
rem / 3600,
rem % 3600 / 60,
rem % 60
)
}
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days + 719_468;
let era = z / 146_097;
let doe = z % 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 + u64::from(month <= 2);
(year, month, day)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_known_instants() {
assert_eq!(format_utc(0), "19700101000000");
assert_eq!(format_utc(951_782_400), "20000229000000"); assert_eq!(format_utc(1_700_000_000), "20231114221320");
assert_eq!(format_utc(1_791_741_599), "20261011175959");
}
#[test]
fn parses_prefixes_back() {
for secs in [0, 951_782_400, 1_700_000_000, 1_791_741_599] {
assert_eq!(
parse_prefix(&format!("{}_x.surql", prefix(secs))),
Some(secs)
);
}
assert_eq!(parse_prefix("README.md"), None);
}
#[test]
fn next_prefix_sorts_after_existing_migrations() {
let dir = std::env::temp_dir().join(format!("slave-ts-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("29990101_000000_later.surql"), "").unwrap();
assert_eq!(next_migration_prefix(&dir).unwrap(), "29990101_000001");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn migration_prefix_has_date_and_time() {
let prefix = migration_prefix_now().unwrap();
assert_eq!(prefix.len(), 15);
assert_eq!(&prefix[8..9], "_");
assert!(prefix.as_str() > "2026");
}
}