glacier-web-cli 0.1.0

Command-line tool for creating and running Glacier applications.
//! `glacier generate …`: code generators that run inside an app.

use std::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};

const MIGRATIONS_DIR: &str = "src/migrations";

/// Writes `src/migrations/m<timestamp>_<name>.rs` and relists every
/// migration in `src/migrations/mod.rs`.
pub fn migration(name: &str) -> Result<(), String> {
    migration_with(name, &migration_source(name))
}

/// Like `migration`, with the file's contents given.
pub fn migration_with(name: &str, source: &str) -> Result<(), String> {
    if !Path::new("Cargo.toml").exists() {
        return Err("no Cargo.toml here; run this from your app's directory".into());
    }
    if name.is_empty()
        || !name
            .chars()
            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
    {
        return Err(format!(
            "invalid migration name `{name}`: use snake_case, e.g. create_posts"
        ));
    }

    let dir = Path::new(MIGRATIONS_DIR);
    fs::create_dir_all(dir).map_err(|e| format!("creating {MIGRATIONS_DIR}: {e}"))?;
    let module = format!(
        "m{}_{name}",
        next_version(&existing_versions(dir)?, now_secs())
    );
    let path = dir.join(format!("{module}.rs"));
    fs::write(&path, source).map_err(|e| format!("writing {}: {e}", path.display()))?;
    println!("      create  {}", path.display());

    write_index(dir)?;
    println!("      update  {MIGRATIONS_DIR}/mod.rs");
    Ok(())
}

fn migration_source(name: &str) -> String {
    let header = "use glacier::db::migration::*;\n\n";
    if let Some(table) = name.strip_prefix("create_") {
        return format!(
            r#"{header}pub fn up(s: &mut Schema) {{
    s.create_table("{table}", |t| {{
        // t.string("title").not_null();
        t.timestamps();
    }});
}}

pub fn down(s: &mut Schema) {{
    s.drop_table("{table}");
}}
"#
        );
    }
    if let Some((column, table)) = name.strip_prefix("add_").and_then(|r| r.split_once("_to_")) {
        return format!(
            r#"{header}pub fn up(s: &mut Schema) {{
    s.alter_table("{table}", |t| {{
        t.string("{column}");
    }});
}}

pub fn down(s: &mut Schema) {{
    s.alter_table("{table}", |t| t.drop_column("{column}"));
}}
"#
        );
    }
    if let Some((column, table)) = name
        .strip_prefix("remove_")
        .and_then(|r| r.split_once("_from_"))
    {
        return format!(
            r#"{header}pub fn up(s: &mut Schema) {{
    s.alter_table("{table}", |t| t.drop_column("{column}"));
}}

pub fn down(s: &mut Schema) {{
    s.alter_table("{table}", |t| {{
        t.string("{column}");
    }});
}}
"#
        );
    }
    format!(
        r#"{header}// Rename `_s` to `s` once you add steps, e.g.
// s.alter_table("posts", |t| {{ t.string("subtitle"); }});
pub fn up(_s: &mut Schema) {{}}

pub fn down(_s: &mut Schema) {{}}
"#
    )
}

/// The timestamp for a new migration: now, or one second after the newest
/// existing migration if that's later, so versions never collide.
fn next_version(existing: &[String], now: i64) -> String {
    let now = utc_timestamp(now);
    match existing.iter().max() {
        Some(latest) if latest.as_str() >= now.as_str() => {
            utc_timestamp(parse_timestamp(latest).map_or(0, |secs| secs + 1))
        }
        _ => now,
    }
}

fn existing_versions(dir: &Path) -> Result<Vec<String>, String> {
    Ok(migration_modules(dir)?
        .iter()
        .map(|m| m[1..].chars().take_while(char::is_ascii_digit).collect())
        .collect())
}

/// Inverse of `utc_timestamp`.
fn parse_timestamp(s: &str) -> Option<i64> {
    let n = |range: std::ops::Range<usize>| s.get(range)?.parse::<i64>().ok();
    let (year, month, day) = (n(0..4)?, n(4..6)?, n(6..8)?);
    let (hour, minute, second) = (n(8..10)?, n(10..12)?, n(12..14)?);
    // Days from civil (Howard Hinnant).
    let y = if month <= 2 { year - 1 } else { year };
    let era = y.div_euclid(400);
    let yoe = y - era * 400;
    let mp = (month + 9) % 12;
    let doy = (153 * mp + 2) / 5 + day - 1;
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    let days = era * 146_097 + doe - 719_468;
    Some(days * 86_400 + hour * 3600 + minute * 60 + second)
}

fn migration_modules(dir: &Path) -> Result<Vec<String>, String> {
    let mut modules: Vec<String> = fs::read_dir(dir)
        .map_err(|e| format!("reading {}: {e}", dir.display()))?
        .filter_map(|entry| {
            let name = entry.ok()?.file_name().into_string().ok()?;
            let module = name.strip_suffix(".rs")?;
            let is_migration = module
                .strip_prefix('m')
                .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit()));
            is_migration.then(|| module.to_owned())
        })
        .collect();
    modules.sort();
    Ok(modules)
}

/// Rewrites `mod.rs` to list every `m<version>_*.rs` file, oldest first.
pub fn write_index(dir: &Path) -> Result<(), String> {
    let modules = migration_modules(dir)?;

    let mut source = String::from(
        "//! The app's migrations, oldest first. `glacier generate migration`\n\
         //! keeps this list up to date.\n\nglacier::migrations! {\n",
    );
    for module in &modules {
        source.push_str(&format!("    {module},\n"));
    }
    source.push_str("}\n");
    fs::write(dir.join("mod.rs"), source).map_err(|e| format!("writing mod.rs: {e}"))
}

fn now_secs() -> i64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_or(0, |d| d.as_secs() as i64)
}

/// `YYYYMMDDHHMMSS` in UTC, without a date library (Howard Hinnant's
/// civil-from-days algorithm).
fn utc_timestamp(secs: i64) -> String {
    let days = secs.div_euclid(86_400);
    let rem = secs.rem_euclid(86_400);
    let (hour, minute, second) = (rem / 3600, rem % 3600 / 60, rem % 60);

    let z = days + 719_468;
    let era = z.div_euclid(146_097);
    let doe = z.rem_euclid(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 + i64::from(month <= 2);
    format!("{year:04}{month:02}{day:02}{hour:02}{minute:02}{second:02}")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn formats_utc_timestamps() {
        assert_eq!(utc_timestamp(0), "19700101000000");
        assert_eq!(utc_timestamp(951_782_400), "20000229000000");
        assert_eq!(utc_timestamp(1_759_449_599), "20251002235959");
    }

    #[test]
    fn versions_never_collide() {
        let now = 951_782_400; // 2000-02-29 00:00:00
        assert_eq!(next_version(&[], now), "20000229000000");
        assert_eq!(
            next_version(&["19991231235959".into()], now),
            "20000229000000"
        );
        assert_eq!(
            next_version(&["20000229000000".into()], now),
            "20000229000001"
        );
        assert_eq!(
            next_version(&["20000228235959".into(), "20000229235959".into()], now),
            "20000301000000"
        );
        assert_eq!(parse_timestamp("20000229000000"), Some(now));
    }

    #[test]
    fn create_migrations_get_a_table() {
        let source = migration_source("create_posts");
        assert!(source.contains(r#"s.create_table("posts""#));
        assert!(source.contains(r#"s.drop_table("posts")"#));
        let add = migration_source("add_slug_to_posts");
        assert!(add.contains(
            r#"s.alter_table("posts", |t| {
        t.string("slug");"#
        ));
        assert!(add.contains(r#"t.drop_column("slug")"#));
        let remove = migration_source("remove_slug_from_posts");
        assert!(remove.contains(r#"s.alter_table("posts", |t| t.drop_column("slug"));"#));
        assert!(migration_source("backfill_things").contains("pub fn up(_s: &mut Schema) {}"));
    }
}