Ctrl K
ComunidadGuías…

build.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//! 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())
}