runnerd-core 0.0.19

Config, domain logic and API types shared by runnerd and its clients
Documentation
//! Timestamps as GitHub and Docker write them (RFC 3339 in UTC, such as `2026-10-22T19:27:25Z` or
//! `2026-10-22T19:27:25.233122085Z`), and the clock as runnerd records it. Pure functions apart from `now_ms`.

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

/// Milliseconds since the Unix epoch, now.
pub(crate) fn now_ms() -> u64 {
    ms(SystemTime::now())
}

/// Milliseconds since the Unix epoch of a time.
pub(crate) fn ms(time: SystemTime) -> u64 {
    time.duration_since(UNIX_EPOCH).map_or(0, |since| {
        u64::try_from(since.as_millis()).unwrap_or(u64::MAX)
    })
}

/// Milliseconds since the Unix epoch of a UTC RFC 3339 timestamp (`Z` only, fractions of a second optional);
/// `None` for anything else.
pub(crate) fn parse_ms(text: &str) -> Option<u64> {
    parse_ns(text).map(|ns| ns / 1_000_000)
}

/// Nanoseconds since the Unix epoch of a UTC RFC 3339 timestamp, as `parse_ms` takes it; Docker's log timestamps
/// have all nine digits.
pub(crate) fn parse_ns(text: &str) -> Option<u64> {
    let text = text.strip_suffix('Z')?;
    let (date, time) = text.split_once('T')?;
    let mut date = date.splitn(3, '-').map(str::parse::<u64>);
    let (year, month, day) = (date.next()?.ok()?, date.next()?.ok()?, date.next()?.ok()?);
    let (time, fraction) = time.split_once('.').unwrap_or((time, ""));
    let mut time = time.splitn(3, ':').map(str::parse::<u64>);
    let (hour, minute, second) = (time.next()?.ok()?, time.next()?.ok()?, time.next()?.ok()?);
    if !(1970..=9999).contains(&year)
        || !(1..=12).contains(&month)
        || !(1..=31).contains(&day)
        || hour > 23
        || minute > 59
        || second > 60
        || !fraction.chars().all(|c| c.is_ascii_digit())
    {
        return None;
    }
    let nanos = format!("{fraction:0<9}")[..9].parse::<u64>().ok()?;
    let days = crate::api::days_from_civil(year, month, day);
    let seconds = ((days * 24 + hour) * 60 + minute) * 60 + second;
    seconds.checked_mul(1_000_000_000)?.checked_add(nanos)
}

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

    #[test]
    fn github_and_docker_timestamps_parse_to_epoch_milliseconds() {
        assert_eq!(parse_ms("1970-01-01T00:00:00Z"), Some(0));
        // `date -u -d 2026-10-22T19:27:25Z +%s`
        assert_eq!(parse_ms("2026-10-22T19:27:25Z"), Some(1_792_697_245_000));
        assert_eq!(
            parse_ms("2026-10-22T19:27:25.233122085Z"),
            Some(1_792_697_245_233)
        );
        assert_eq!(parse_ms("2024-02-29T12:00:00.5Z"), Some(1_709_208_000_500));
        for invalid in [
            "",
            "2026-10-22T19:27:25",
            "2026-13-01T00:00:00Z",
            "yesterday",
        ] {
            assert_eq!(parse_ms(invalid), None, "{invalid}");
        }
    }
}