use std::time::{SystemTime, UNIX_EPOCH}; /// Format as `YYYYMMDD-HHMMSS-ffffff` (build directories and log files). #[must_use] pub fn format_build_timestamp(t: SystemTime) -> String { let dur = t.duration_since(UNIX_EPOCH).unwrap_or_default(); let (year, month, day, hour, min, sec) = epoch_to_civil(dur.as_secs()); let us = dur.subsec_micros(); format!("{year:04}{month:02}{day:02}-{hour:02}{min:02}{sec:02}-{us:06}") } /// Format as `YYYY-MM-DDTHH:MM:SSZ` (state.json `started_at`, second precision). #[must_use] pub fn format_rfc3339(t: SystemTime) -> String { let dur = t.duration_since(UNIX_EPOCH).unwrap_or_default(); let (year, month, day, hour, min, sec) = epoch_to_civil(dur.as_secs()); format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}Z") } /// Format as `YYYY-MM-DDTHH:MM:SS.mmmZ` (logger console output, millisecond precision, UTC). #[must_use] pub fn format_log_timestamp(t: SystemTime) -> String { let dur = t.duration_since(UNIX_EPOCH).unwrap_or_default(); let (year, month, day, hour, min, sec) = epoch_to_civil(dur.as_secs()); let ms = dur.subsec_millis(); format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}.{ms:03}Z") } /// Parse `YYYY-MM-DDTHH:MM:SSZ` back to `SystemTime`. Only handles `Z` suffix. #[must_use] pub fn parse_rfc3339(s: &str) -> Option { let s = s.strip_suffix('Z')?; if s.len() != 19 { return None; } let bytes = s.as_bytes(); if bytes.get(4) != Some(&b'-') || bytes.get(7) != Some(&b'-') || bytes.get(10) != Some(&b'T') || bytes.get(13) != Some(&b':') || bytes.get(16) != Some(&b':') { return None; } let year: u16 = s.get(0..4)?.parse().ok()?; let month: u8 = s.get(5..7)?.parse().ok()?; let day: u8 = s.get(8..10)?.parse().ok()?; let hour: u8 = s.get(11..13)?.parse().ok()?; let min: u8 = s.get(14..16)?.parse().ok()?; let sec: u8 = s.get(17..19)?.parse().ok()?; if !(1..=12).contains(&month) || !(1..=31).contains(&day) || hour > 23 || min > 59 || sec > 59 { return None; } let epoch = civil_to_epoch(year, month, day, hour, min, sec); Some(UNIX_EPOCH + std::time::Duration::from_secs(epoch)) } /// Convert Unix epoch seconds to (year, month, day, hour, minute, second). /// Uses Howard Hinnant's `civil_from_days` algorithm. /// /// # Safety (casts) /// All `as` casts are bounded by the civil-date algorithm: /// year fits u16 (0–9999), month/day/h/m/s fit u8, day-count fits i64. #[expect( clippy::cast_possible_truncation, clippy::cast_possible_wrap, clippy::cast_sign_loss, reason = "Hinnant civil_from_days algorithm: values bounded by calendar math" )] const fn epoch_to_civil(secs: u64) -> (u16, u8, u8, u8, u8, u8) { let day_secs = secs % 86400; let days = (secs / 86400) as i64 + 719_468; let era = days.div_euclid(146_097); let doe = days.rem_euclid(146_097) as u64; // day of era [0, 146096] let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // year of era let year = (yoe as i64) + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // day of year [0, 365] let mp = (5 * doy + 2) / 153; // [0, 11] let day = (doy - (153 * mp + 2) / 5 + 1) as u8; let month = if mp < 10 { mp + 3 } else { mp - 9 } as u8; let year = if month <= 2 { year + 1 } else { year } as u16; let hour = (day_secs / 3600) as u8; let min = ((day_secs % 3600) / 60) as u8; let sec = (day_secs % 60) as u8; (year, month, day, hour, min, sec) } /// Convert (year, month, day, hour, minute, second) to Unix epoch seconds. /// /// # Safety (casts) /// All `as` casts are bounded by the civil-date algorithm: /// year fits i64, month/day/h/m/s fit u64, `doe` fits i64 (0–146096). /// Final `as u64` is non-negative for all valid civil dates. #[expect( clippy::cast_possible_wrap, clippy::cast_sign_loss, reason = "Hinnant civil_from_days algorithm: values bounded by calendar math" )] const fn civil_to_epoch(year: u16, month: u8, day: u8, hour: u8, min: u8, sec: u8) -> u64 { let year = (year as i64) - (month <= 2) as i64; let era = year.div_euclid(400); let yoe = year.rem_euclid(400) as u64; // year of era [0, 399] let m_adj = if month > 2 { (month as u64) - 3 } else { (month as u64) + 9 }; // [0, 11] let doy = (153 * m_adj + 2) / 5 + (day as u64) - 1; // day of year [0, 365] let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // day of era [0, 146096] let days = (era * 146_097 + doe as i64 - 719_468) as u64; days * 86400 + (hour as u64) * 3600 + (min as u64) * 60 + (sec as u64) } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use super::*; use std::time::Duration; #[test] fn format_build_timestamp_unix_epoch() { assert_eq!(format_build_timestamp(UNIX_EPOCH), "19700101-000000-000000"); } #[test] fn format_build_timestamp_format() { let s = format_build_timestamp(SystemTime::now()); let parts: Vec<&str> = s.split('-').collect(); assert_eq!(parts.len(), 3, "expected 3 dash-separated parts, got: {s}"); assert_eq!(parts[0].len(), 8, "date part should be 8 digits"); assert_eq!(parts[1].len(), 6, "time part should be 6 digits"); assert_eq!(parts[2].len(), 6, "micros part should be 6 digits"); assert!(parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit()))); } #[test] fn format_rfc3339_unix_epoch() { assert_eq!(format_rfc3339(UNIX_EPOCH), "1970-01-01T00:00:00Z"); } #[test] fn format_log_timestamp_unix_epoch() { assert_eq!(format_log_timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z"); } #[test] fn format_rfc3339_known_date() { // 2024-02-29T12:30:45Z (leap year) let secs = civil_to_epoch(2024, 2, 29, 12, 30, 45); let t = UNIX_EPOCH + Duration::from_secs(secs); assert_eq!(format_rfc3339(t), "2024-02-29T12:30:45Z"); } #[test] fn parse_rfc3339_roundtrip() { let now = SystemTime::now(); let s = format_rfc3339(now); let parsed = parse_rfc3339(&s).unwrap(); // Roundtrip loses sub-second precision, so compare formatted output assert_eq!(format_rfc3339(parsed), s); } #[test] fn parse_rfc3339_valid() { let t = parse_rfc3339("2026-02-13T14:30:00Z").unwrap(); assert_eq!(format_rfc3339(t), "2026-02-13T14:30:00Z"); } #[test] fn parse_rfc3339_rejects_plus_offset() { assert!(parse_rfc3339("2026-02-13T14:30:00+00:00").is_none()); } #[test] fn parse_rfc3339_rejects_garbage() { assert!(parse_rfc3339("not-a-date").is_none()); assert!(parse_rfc3339("").is_none()); assert!(parse_rfc3339("2026-13-01T00:00:00Z").is_none()); // month 13 assert!(parse_rfc3339("2026-00-01T00:00:00Z").is_none()); // month 0 } #[test] fn epoch_to_civil_roundtrip() { let dates: &[(u16, u8, u8, u8, u8, u8)] = &[ (1970, 1, 1, 0, 0, 0), (2000, 1, 1, 0, 0, 0), (2024, 2, 29, 23, 59, 59), // leap year (2024, 12, 31, 12, 0, 0), (2026, 2, 13, 14, 30, 0), ]; for &(y, m, d, h, min, s) in dates { let epoch = civil_to_epoch(y, m, d, h, min, s); let (y2, m2, d2, h2, min2, s2) = epoch_to_civil(epoch); assert_eq!( (y, m, d, h, min, s), (y2, m2, d2, h2, min2, s2), "roundtrip failed for {y}-{m:02}-{d:02}T{h:02}:{min:02}:{s:02}Z" ); } } #[test] fn format_log_timestamp_millisecond_precision() { let t = UNIX_EPOCH + Duration::from_millis(1_234_567_890_123); let s = format_log_timestamp(t); assert!(s.ends_with("Z")); assert!(s.contains('.')); // The milliseconds portion should be 3 digits let dot_pos = s.find('.').unwrap(); assert_eq!(&s[dot_pos + 4..], "Z"); } #[test] fn format_build_timestamp_microsecond_precision() { let t = UNIX_EPOCH + Duration::from_micros(1_234_567_890_123_456); let s = format_build_timestamp(t); // Last 6 chars before end should be microseconds let parts: Vec<&str> = s.split('-').collect(); assert_eq!(parts.len(), 3); assert_eq!(parts[2].len(), 6); } }