build.rs
Rama main · réplica de lectura del repositorio original.
//! 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 <description>`.
//!
//! 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 <description>",
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<usize>| ts[range].parse::<u32>().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())
}