use time::OffsetDateTime;
pub const MAX_SLUG_LEN: usize = 63;
pub const MIGRATION_FILE_EXT: &str = ".sdjql";
pub const MIGRATION_FILE_EXT_LEN: usize = MIGRATION_FILE_EXT.len();
pub(crate) const MIGRATION_DOWN_SUFFIX: &str = ".down.sdjql";
const DIGIT_LEAD_PREFIX: &str = "m_";
pub fn sanitize_slug(input: &str) -> String {
let mut out: Vec<u8> = Vec::with_capacity(input.len().min(MAX_SLUG_LEN));
let mut last_was_underscore = true; for byte in input.bytes() {
let mapped: Option<u8> = match byte {
b'A'..=b'Z' => Some(byte + 32),
b'a'..=b'z' => Some(byte),
b'0'..=b'9' => Some(byte),
b'_' => Some(b'_'),
b' ' | b'\t' | b'\n' | b'\r' | b'-' | b'.' => Some(b'_'),
_ => None,
};
let Some(b) = mapped else {
continue;
};
if b == b'_' {
if last_was_underscore {
continue;
}
last_was_underscore = true;
out.push(b);
} else {
last_was_underscore = false;
out.push(b);
}
if out.len() >= MAX_SLUG_LEN + DIGIT_LEAD_PREFIX.len() {
break;
}
}
while out.last() == Some(&b'_') {
out.pop();
}
if out.is_empty() {
return "migration".to_string();
}
if let Some(&first) = out.first()
&& first.is_ascii_digit()
{
let mut prefixed: Vec<u8> = Vec::with_capacity(out.len() + DIGIT_LEAD_PREFIX.len());
prefixed.extend_from_slice(DIGIT_LEAD_PREFIX.as_bytes());
prefixed.extend_from_slice(&out);
out = prefixed;
}
if out.len() > MAX_SLUG_LEN {
out.truncate(MAX_SLUG_LEN);
while out.last() == Some(&b'_') {
out.pop();
}
}
String::from_utf8(out).expect("sanitiser only emits ASCII")
}
pub fn version_prefix(instant: OffsetDateTime) -> String {
let utc = instant.to_offset(time::UtcOffset::UTC);
let mut s = String::with_capacity(15);
s.push('V');
push_pad4(&mut s, utc.year() as u32);
push_pad2(&mut s, utc.month() as u8 as u32);
push_pad2(&mut s, utc.day() as u32);
push_pad2(&mut s, utc.hour() as u32);
push_pad2(&mut s, utc.minute() as u32);
push_pad2(&mut s, utc.second() as u32);
s
}
pub fn version_id(prefix: &str, slug: &str) -> String {
let mut s = String::with_capacity(prefix.len() + 2 + slug.len());
s.push_str(prefix);
s.push_str("__");
s.push_str(slug);
s
}
pub fn up_filename(version: &str) -> String {
format!("{version}{ext}", ext = MIGRATION_FILE_EXT)
}
pub fn down_filename(version: &str) -> String {
format!("{version}{suffix}", suffix = MIGRATION_DOWN_SUFFIX)
}
pub fn pending_json_filename(app_label: &str) -> String {
if app_label.is_empty() {
"_global_.json".to_string()
} else {
format!("{app_label}.json")
}
}
fn push_pad4(s: &mut String, n: u32) {
let _ = std::fmt::Write::write_fmt(s, format_args!("{n:04}"));
}
fn push_pad2(s: &mut String, n: u32) {
let _ = std::fmt::Write::write_fmt(s, format_args!("{n:02}"));
}
#[cfg(test)]
mod tests {
use super::*;
fn at(year: i32, month: u8, day: u8, hour: u8, minute: u8, second: u8) -> OffsetDateTime {
let date = time::Date::from_calendar_date(year, time::Month::try_from(month).unwrap(), day)
.unwrap();
let time = time::Time::from_hms(hour, minute, second).unwrap();
date.with_time(time).assume_utc()
}
#[test]
fn empty_input_produces_migration_fallback() {
assert_eq!(sanitize_slug(""), "migration");
assert_eq!(sanitize_slug(" "), "migration");
assert_eq!(sanitize_slug("___"), "migration");
assert_eq!(sanitize_slug("!!!"), "migration");
}
#[test]
fn ascii_passthrough() {
assert_eq!(sanitize_slug("add_users"), "add_users");
assert_eq!(sanitize_slug("AddUsers"), "addusers");
}
#[test]
fn whitespace_collapses_to_single_underscore() {
assert_eq!(sanitize_slug("add users"), "add_users");
assert_eq!(sanitize_slug("add users"), "add_users");
assert_eq!(sanitize_slug("add\tusers"), "add_users");
assert_eq!(sanitize_slug("add-users"), "add_users");
}
#[test]
fn punctuation_dropped() {
assert_eq!(sanitize_slug("add!@#$%^&*users"), "addusers");
assert_eq!(sanitize_slug("add(users)"), "addusers");
}
#[test]
fn multibyte_utf8_dropped() {
assert_eq!(sanitize_slug("add_zażółć_gęślą_jaźń"), "add_za_gl_ja");
assert_eq!(sanitize_slug("Привет"), "migration");
}
#[test]
fn leading_digits_prefixed_with_m() {
assert_eq!(sanitize_slug("123_things"), "m_123_things");
}
#[test]
fn leading_trailing_underscore_trimmed() {
assert_eq!(sanitize_slug("___add_users___"), "add_users");
}
#[test]
fn length_cap_at_63_bytes() {
let long = "a".repeat(80);
let s = sanitize_slug(&long);
assert_eq!(s.len(), MAX_SLUG_LEN);
assert!(s.bytes().all(|b| b == b'a'));
}
#[test]
fn length_cap_does_not_leave_trailing_underscore() {
let mut input = String::new();
for _ in 0..62 {
input.push('a');
}
input.push_str("____");
let s = sanitize_slug(&input);
assert!(!s.ends_with('_'), "got {s:?}");
assert_eq!(s.len(), 62);
}
#[test]
fn version_prefix_byte_stable() {
let when = at(2026, 4, 25, 1, 2, 3);
assert_eq!(version_prefix(when), "V20260425010203");
}
#[test]
fn version_prefix_pads_single_digit_components() {
let when = at(2026, 1, 2, 3, 4, 5);
assert_eq!(version_prefix(when), "V20260102030405");
}
#[test]
fn version_prefix_lowest_minute() {
let when = at(2026, 12, 31, 23, 59, 59);
assert_eq!(version_prefix(when), "V20261231235959");
}
#[test]
fn version_id_concatenates_with_double_underscore() {
let v = version_id("V20260425010203", "add_users");
assert_eq!(v, "V20260425010203__add_users");
}
#[test]
fn up_and_down_filenames() {
let v = "V20260425010203__add_users";
assert_eq!(up_filename(v), "V20260425010203__add_users.sdjql");
assert_eq!(down_filename(v), "V20260425010203__add_users.down.sdjql",);
}
#[test]
fn migration_file_ext_constant_has_expected_value() {
assert_eq!(MIGRATION_FILE_EXT, ".sdjql");
assert_eq!(MIGRATION_FILE_EXT_LEN, 6); }
#[test]
fn up_filename_uses_sdjql_extension() {
let v = "V20260425010203__add_users";
assert_eq!(up_filename(v), "V20260425010203__add_users.sdjql");
}
#[test]
fn down_filename_uses_down_sdjql_extension() {
let v = "V20260425010203__add_users";
assert_eq!(down_filename(v), "V20260425010203__add_users.down.sdjql");
}
#[test]
fn down_side_contains_dot_down_marker() {
let v = "V20260425010203__add_users";
let down = down_filename(v);
assert!(
down.contains(".down."),
"down filename must contain .down. marker: {down}"
);
}
#[test]
fn pending_json_global_bucket_uses_stable_token() {
assert_eq!(pending_json_filename(""), "_global_.json");
assert_eq!(pending_json_filename("billing"), "billing.json");
}
#[test]
fn sanitize_byte_stable_on_repeat() {
let a = sanitize_slug("Add some VERY long migration name -- with junk!!!!");
let b = sanitize_slug("Add some VERY long migration name -- with junk!!!!");
assert_eq!(a, b);
}
}