slave 0.2.0

Laravel-inspired CLI for Rust web apps: scaffold Axum projects with built-in auth, users, roles and Turso migrations
//! UTC `YYYYMMDDHHMMSS` timestamps for migration file names, without a date-time dependency.

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

use anyhow::{Context, Result};

/// `count` consecutive UTC timestamps one second apart, starting now.
pub fn utc_now_sequence(count: u64) -> Result<Vec<String>> {
    let now = now_secs()?;
    Ok((0..count).map(|i| format_utc(now + i)).collect())
}

/// The current UTC time, or one second after `latest` if that is not in the past.
///
/// Keeps migration timestamps strictly increasing, so creation order and name
/// order agree even for migrations created within the same second.
pub fn utc_now_after(latest: Option<&str>) -> Result<String> {
    let now = now_secs()?;
    let next = latest
        .and_then(parse_utc)
        .map_or(now, |latest| now.max(latest + 1));
    Ok(format_utc(next))
}

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

/// Parses a `YYYYMMDDHHMMSS` timestamp into Unix seconds.
fn parse_utc(text: &str) -> Option<u64> {
    if text.len() != 14 || !text.bytes().all(|b| b.is_ascii_digit()) {
        return None;
    }
    let field = |range: std::ops::Range<usize>| text[range].parse::<u64>().ok();
    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)?);
    if year < 1970 || !(1..=12).contains(&month) || !(1..=31).contains(&day) {
        return None;
    }
    let days = days_from_civil(year, month, day);
    Some(days * 86_400 + hour * 3600 + minute * 60 + second)
}

/// Converts a (year, month, day) date on or after 1970-01-01 to days since the epoch.
fn days_from_civil(year: u64, month: u64, day: u64) -> u64 {
    let year = if month <= 2 { year - 1 } else { year };
    let era = year / 400;
    let yoe = year % 400;
    let mp = (month + 9) % 12;
    let doy = (153 * mp + 2) / 5 + day - 1;
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    era * 146_097 + doe - 719_468
}

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_what_it_formats() {
        for secs in [0, 951_782_400, 1_700_000_000, 1_791_741_599] {
            assert_eq!(parse_utc(&format_utc(secs)), Some(secs));
        }
        assert_eq!(parse_utc("2026101616"), None);
        assert_eq!(parse_utc("2026x016162020"), None);
    }

    #[test]
    fn stays_after_latest() {
        let future = "29991231235959";
        assert_eq!(utc_now_after(Some(future)).unwrap(), "30000101000000");
        let now = utc_now_after(Some("19700101000000")).unwrap();
        assert!(now.as_str() > "2026");
        assert!(utc_now_after(None).unwrap().as_str() > "2026");
    }

    #[test]
    fn sequences_are_strictly_increasing() {
        let seq = utc_now_sequence(3).unwrap();
        assert_eq!(seq.len(), 3);
        assert!(seq[0] < seq[1] && seq[1] < seq[2]);
    }

    #[test]
    fn now_has_fourteen_digits() {
        let now = utc_now_after(None).unwrap();
        assert_eq!(now.len(), 14);
        assert!(now.chars().all(|c| c.is_ascii_digit()));
    }
}