slave 0.13.0

CLI for slave, a Laravel-inspired Rust framework for MCP-first apps: scaffold an MCP backend with auth, roles, teams and SurrealDB migrations, add server-rendered web apps, and route them by domain or path
//! UTC timestamps for migration file names, without a date-time dependency.

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

use anyhow::{Context, Result};

/// The current UTC time as a migration prefix, `YYYYMMDD_HHMMSS`.
pub fn migration_prefix_now() -> Result<String> {
    Ok(prefix(now_secs()?))
}

/// A migration prefix for a new migration in `dir`: now, or one second after
/// the newest existing migration if that is not earlier, so the new one
/// always sorts (and runs) last.
pub fn next_migration_prefix(dir: &Path) -> Result<String> {
    let latest = std::fs::read_dir(dir)
        .into_iter()
        .flatten()
        .flatten()
        .filter_map(|entry| parse_prefix(&entry.file_name().to_string_lossy()))
        .max();
    let now = now_secs()?;
    Ok(prefix(latest.map_or(now, |latest| now.max(latest + 1))))
}

fn prefix(unix_secs: u64) -> String {
    let stamp = format_utc(unix_secs);
    format!("{}_{}", &stamp[..8], &stamp[8..])
}

/// The time in a `YYYYMMDD_HHMMSS_...` file name, as Unix seconds.
fn parse_prefix(name: &str) -> Option<u64> {
    let (date, rest) = (name.get(..8)?, name.get(8..15)?);
    let time = rest.strip_prefix('_')?;
    if !date.bytes().chain(time.bytes()).all(|b| b.is_ascii_digit()) {
        return None;
    }
    let num = |s: &str| s.parse::<u64>().ok();
    let days = days_from_civil(num(&date[..4])?, num(&date[4..6])?, num(&date[6..])?)?;
    Some(days * 86_400 + num(&time[..2])? * 3600 + num(&time[2..4])? * 60 + num(&time[4..])?)
}

/// Days since 1970-01-01 of a date after the epoch (inverse of
/// [`civil_from_days`]).
fn days_from_civil(year: u64, month: u64, day: u64) -> Option<u64> {
    if !(1..=12).contains(&month) || !(1..=31).contains(&day) || year < 1970 {
        return None;
    }
    let year = if month <= 2 { year - 1 } else { year };
    let era = year / 400;
    let yoe = year - era * 400;
    let mp = if month > 2 { month - 3 } else { month + 9 };
    let doy = (153 * mp + 2) / 5 + day - 1;
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    (era * 146_097 + doe).checked_sub(719_468)
}

fn now_secs() -> Result<u64> {
    Ok(SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .context("system clock is set before 1970")?
        .as_secs())
}

fn format_utc(unix_secs: u64) -> String {
    let (days, rem) = (unix_secs / 86_400, unix_secs % 86_400);
    let (year, month, day) = civil_from_days(days);
    format!(
        "{year:04}{month:02}{day:02}{:02}{:02}{:02}",
        rem / 3600,
        rem % 3600 / 60,
        rem % 60
    )
}

/// Converts days since 1970-01-01 to a (year, month, day) date.
/// Howard Hinnant's `civil_from_days` algorithm, restricted to dates after the epoch.
fn civil_from_days(days: u64) -> (u64, u64, u64) {
    let z = days + 719_468;
    let era = z / 146_097;
    let doe = z % 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 + u64::from(month <= 2);
    (year, month, day)
}

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

    #[test]
    fn formats_known_instants() {
        assert_eq!(format_utc(0), "19700101000000");
        assert_eq!(format_utc(951_782_400), "20000229000000"); // leap day
        assert_eq!(format_utc(1_700_000_000), "20231114221320");
        assert_eq!(format_utc(1_791_741_599), "20261011175959");
    }

    #[test]
    fn parses_prefixes_back() {
        for secs in [0, 951_782_400, 1_700_000_000, 1_791_741_599] {
            assert_eq!(
                parse_prefix(&format!("{}_x.surql", prefix(secs))),
                Some(secs)
            );
        }
        assert_eq!(parse_prefix("README.md"), None);
    }

    #[test]
    fn next_prefix_sorts_after_existing_migrations() {
        let dir = std::env::temp_dir().join(format!("slave-ts-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        // A migration from the future (or from this very second).
        std::fs::write(dir.join("29990101_000000_later.surql"), "").unwrap();
        assert_eq!(next_migration_prefix(&dir).unwrap(), "29990101_000001");
        std::fs::remove_dir_all(&dir).unwrap();
    }

    #[test]
    fn migration_prefix_has_date_and_time() {
        let prefix = migration_prefix_now().unwrap();
        assert_eq!(prefix.len(), 15);
        assert_eq!(&prefix[8..9], "_");
        assert!(prefix.as_str() > "2026");
    }
}