glacier-web-cli 0.5.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};

/// Where an app made before 0.4 keeps its migrations, listed in a
/// `mod.rs`; newer apps keep them in `db/migrations/`, found by
/// `glacier::migrations!()`.
const OLD_MIGRATIONS_DIR: &str = "src/migrations";
const MIGRATIONS_DIR: &str = "db/migrations";

/// The app's migrations folder, and whether it keeps a `mod.rs` list
/// (an app made before 0.4).
pub(crate) fn migrations_dir() -> (&'static str, bool) {
    if Path::new(OLD_MIGRATIONS_DIR).join("mod.rs").exists() {
        (OLD_MIGRATIONS_DIR, true)
    } else {
        (MIGRATIONS_DIR, false)
    }
}

/// Writes `db/migrations/m<timestamp>_<name>.glacier`. (In an app made
/// before 0.4, `src/migrations/`, relisting them in its `mod.rs`.)
pub fn migration(name: &str) -> Result<(), String> {
    migration_with(name, &migration_source(name))
}

/// `glacier generate migration create_book_views --database analytics`:
/// a migration for a database the app names (`[databases.analytics]`),
/// in `db/analytics/migrations/` (beside `db/analytics/schema.sql`), which
/// `glacier db migrate` runs after the app's own. The first one registers
/// the folder in `src/lib.rs`.
pub fn named_migration(name: &str, database: &str) -> Result<(), String> {
    if database.is_empty()
        || !database
            .chars()
            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
    {
        return Err(format!(
            "invalid database name `{database}`: use the name from [databases.<name>], e.g. analytics"
        ));
    }
    let folder = format!("{database}_migrations");
    // An app made before 0.4 keeps them in src/<name>_migrations/, listed.
    let old_dir = format!("src/{folder}");
    let old = Path::new(&old_dir).join("mod.rs").exists();
    let dir = if old {
        old_dir
    } else {
        format!("db/{database}/migrations")
    };
    let new_folder = !Path::new(&dir).exists();
    write_migration(Path::new(&dir), name, &migration_source(name))?;
    if old {
        write_index_titled(
            Path::new(&dir),
            &format!(
                "//! The {database} database's migrations, oldest first. `glacier db\n\
                 //! migrate` runs them after the app's own."
            ),
        )?;
        println!("      update  {dir}/mod.rs");
    }
    // `glacier::app!()` finds db/<name>/migrations/ and registers it.
    if new_folder && !crate::scaffold::discovers() {
        let lib_path = "src/lib.rs";
        let lib = fs::read_to_string(lib_path).map_err(|e| format!("reading {lib_path}: {e}"))?;
        let mut updated = lib.clone();
        let declare = if old {
            format!("pub mod {folder};\n")
        } else {
            format!("glacier::migrations!({database});\n")
        };
        if !updated.contains(&declare) {
            let anchor = ["glacier::migrations!();\n", "pub mod migrations;\n"]
                .iter()
                .find_map(|anchor| updated.find(anchor).map(|at| at + anchor.len()));
            updated = match anchor {
                Some(at) => format!("{}{declare}{}", &updated[..at], &updated[at..]),
                None => format!("{declare}{updated}"),
            };
        }
        let register = format!(".named_migrations(\"{database}\", {folder}::all())");
        if !updated.contains(&register) {
            let anchor = ".migrations(migrations::all())";
            match updated.find(anchor) {
                Some(at) => {
                    let line_start = updated[..at].rfind('\n').map_or(0, |n| n + 1);
                    let indent = updated[line_start..at].to_owned();
                    let end = at + anchor.len();
                    updated.insert_str(end, &format!("\n{indent}{register}"));
                }
                None => println!("\nRegister them on your App in {lib_path}:\n\n    {register}"),
            }
        }
        if updated != lib {
            fs::write(lib_path, updated).map_err(|e| format!("writing {lib_path}: {e}"))?;
            println!("      update  {lib_path}");
        }
    }
    Ok(())
}

/// 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, listed) = migrations_dir();
    write_migration(Path::new(dir), name, source)?;
    if listed {
        write_index(Path::new(dir))?;
        println!("      update  {dir}/mod.rs");
    }
    Ok(())
}

/// Writes the migration file in `dir`.
fn write_migration(dir: &Path, name: &str, source: &str) -> Result<(), String> {
    fs::create_dir_all(dir).map_err(|e| format!("creating {}: {e}", dir.display()))?;
    let module = format!(
        "m{}_{name}",
        next_version(&existing_versions(dir)?, now_secs())
    );
    // `.glacier` for a `migration do` block, `.rs` for Rust.
    let extension = if source.lines().any(|l| l.trim() == "migration do") {
        "glacier"
    } else {
        "rs"
    };
    let path = dir.join(format!("{module}.{extension}"));
    fs::write(&path, source).map_err(|e| format!("writing {}: {e}", path.display()))?;
    println!("      create  {}", path.display());
    Ok(())
}

fn migration_source(name: &str) -> String {
    if let Some(table) = name.strip_prefix("create_") {
        return format!(
            "migration do\n  create_table :{table} do\n    # string :title, null: false\n    timestamps\n  end\nend\n"
        );
    }
    if let Some((column, table)) = name.strip_prefix("add_").and_then(|r| r.split_once("_to_")) {
        return format!(
            "migration do\n  change_table :{table} do\n    string :{column}\n  end\nend\n"
        );
    }
    if let Some((column, table)) = name
        .strip_prefix("remove_")
        .and_then(|r| r.split_once("_from_"))
    {
        return format!(
            "# Removing a column can't be undone on its own: `down` says what it was.\n\
             migration do\n  up do\n    change_table :{table} do\n      remove :{column}\n    end\n  end\n\n  \
             down do\n    change_table :{table} do\n      string :{column}\n    end\n  end\nend\n"
        );
    }
    "# Steps go here: create_table, change_table, add_index, rename_table; each\n\
     # is undone by itself on rollback. A step that can't be (drop_table,\n\
     # execute, remove) goes in `up do … end`, with `down do … end` beside it.\n\
     migration do\nend\n"
        .to_owned()
}

/// 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")
                .or_else(|| name.strip_suffix(".glacier"))?;
            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>_*.glacier` or `.rs` file,
/// oldest first.
pub fn write_index(dir: &Path) -> Result<(), String> {
    write_index_titled(
        dir,
        "//! The app's migrations, oldest first. `glacier generate migration`\n\
         //! keeps this list up to date.",
    )
}

fn write_index_titled(dir: &Path, title: &str) -> Result<(), String> {
    let modules = migration_modules(dir)?;

    let mut source = format!("{title}\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 migration_templates_compile() {
        let rust = |name: &str| {
            glacier_dialect::transpile(&migration_source(name), &format!("m1_{name}.glacier"))
                .unwrap_or_else(|e| panic!("{name}: {e}"))
                .rust
        };
        let create = rust("create_posts");
        assert!(create.contains(r#"s.create_table("posts""#), "{create}");
        assert!(create.contains(r#"s.drop_table("posts")"#), "{create}");
        let add = rust("add_slug_to_posts");
        assert!(add.contains(r#"t.string("slug");"#), "{add}");
        assert!(add.contains(r#"t.drop_column("slug");"#), "{add}");
        let remove = rust("remove_slug_from_posts");
        assert!(remove.contains(r#"t.drop_column("slug");"#), "{remove}");
        assert!(rust("backfill_things").contains("pub fn down(s: &mut Schema)"));
    }
}