//! Embeds the SQL migrations from `migrations/` into the binary. //! //! Writes `$OUT_DIR/migrations.rs`, a sorted `MIGRATIONS` list of //! `(name, sql)` pairs that `db::migrate()` applies. The name is the file //! stem and is what `_migrations` records, so it must never change once a //! migration has shipped. //! //! Two naming schemes are accepted (#333): //! - `NNN_description.sql`: the legacy numbered migrations, frozen at 081. //! - `YYYYMMDDHHMMSS_description.sql`: every new migration, timestamp in UTC. //! Create one with `scripts/new-migration.sh `. //! //! Both sort as plain strings, and `0` sorts before `2`, so all legacy //! migrations run before any timestamped one. use std::collections::BTreeMap; use std::fmt::Write as _; use std::path::Path; /// The last numbered migration. Anything after it uses a timestamp. const LAST_LEGACY: u32 = 81; fn main() { let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); let dir = Path::new(&manifest_dir).join("migrations"); println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=migrations"); let mut by_name = BTreeMap::new(); let mut prefixes = BTreeMap::new(); let mut errors = Vec::new(); for entry in std::fs::read_dir(&dir).expect("cannot read migrations/") { let path = entry.expect("cannot read migrations/ entry").path(); let Some(file) = path.file_name().and_then(|f| f.to_str()) else { errors.push(format!("{}: file name is not UTF-8", path.display())); continue; }; if file.starts_with('.') { // Editor lock and swap files (Emacs `.#name.sql`, vim `.name.swp`). continue; } let Some(stem) = file.strip_suffix(".sql") else { if file.to_ascii_lowercase().ends_with(".sql") { errors.push(format!("{file}: the extension must be lowercase .sql")); } continue; }; match check_name(stem) { Ok(prefix) => { if let Some(other) = prefixes.insert(prefix.to_string(), stem.to_string()) { errors.push(format!( "{file}: prefix {prefix} is also used by {other}.sql; \ run scripts/new-migration.sh to get a fresh timestamp" )); } match std::fs::read_to_string(&path) { Ok(sql) if is_empty_sql(&sql) => errors.push(format!( "{file}: no SQL statement yet; write the migration before building, \ or an empty one gets recorded as applied on your database" )), Ok(_) => {} Err(e) => errors.push(format!("{file}: cannot read: {e}")), } by_name.insert(stem.to_string(), path); } Err(why) => errors.push(format!("{file}: {why}")), } } if !errors.is_empty() { for e in &errors { eprintln!("error: migrations/{e}"); } panic!( "{} invalid migration file name(s), see above. New migrations are named \ YYYYMMDDHHMMSS_description.sql (UTC); create one with \ scripts/new-migration.sh ", errors.len() ); } let mut out = String::from( "/// Generated by build.rs from `migrations/`, sorted by name.\n\ const MIGRATIONS: &[(&str, &str)] = &[\n", ); for (name, path) in &by_name { let path = path.to_str().expect("migrations path is not UTF-8"); writeln!(out, " ({name:?}, include_str!({path:?})),").unwrap(); } out.push_str("];\n"); let out_dir = std::env::var("OUT_DIR").unwrap(); std::fs::write(Path::new(&out_dir).join("migrations.rs"), out) .expect("cannot write migrations.rs"); } /// Validates a migration file stem and returns its unique prefix (the /// number or the timestamp). pub(crate) fn check_name(stem: &str) -> Result<&str, String> { let Some((prefix, description)) = stem.split_once('_') else { return Err("expected YYYYMMDDHHMMSS_description.sql".into()); }; if !description.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit()) || !description .bytes() .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') { return Err( "the description must be snake_case: a-z, 0-9 and _, starting with a-z or 0-9".into(), ); } if !prefix.bytes().all(|b| b.is_ascii_digit()) { return Err("expected YYYYMMDDHHMMSS_description.sql".into()); } match prefix.len() { 3 => { let n: u32 = prefix.parse().unwrap(); if n == 0 || n > LAST_LEGACY { return Err(format!( "numbered migrations stop at {LAST_LEGACY:03}; name new ones \ YYYYMMDDHHMMSS_description.sql" )); } } 14 => check_timestamp(prefix)?, _ => return Err("expected YYYYMMDDHHMMSS_description.sql".into()), } Ok(prefix) } fn check_timestamp(ts: &str) -> Result<(), String> { let field = |range: std::ops::Range| ts[range].parse::().unwrap(); let (year, month, day) = (field(0..4), field(4..6), field(6..8)); let (hour, minute, second) = (field(8..10), field(10..12), field(12..14)); let days_in_month = match month { 2 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) => 29, 2 => 28, 4 | 6 | 9 | 11 => 30, _ => 31, }; let valid = (2026..=2099).contains(&year) && (1..=12).contains(&month) && (1..=days_in_month).contains(&day) && hour <= 23 && minute <= 59 && second <= 59; if valid { Ok(()) } else { Err(format!( "{ts} is not a valid YYYYMMDDHHMMSS timestamp (UTC, year 2026 or later)" )) } } /// True when the file holds nothing but whitespace and `--` comments. pub(crate) fn is_empty_sql(sql: &str) -> bool { sql.lines() .map(|line| line.split("--").next().unwrap_or("")) .all(|code| code.trim().is_empty()) }